Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts
T
2026-07-13 13:32:57 +08:00

2945 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ClickHouse,
LlmMetricsV1Input,
MetricsV1Input,
TaskEventDetailedSummaryV1Result,
TaskEventDetailsV1Result,
TaskEventSummaryV1Result,
TaskEventV1Input,
TaskEventV2Input,
} from "@internal/clickhouse";
import type { Attributes, Counter, Meter, Tracer } from "@internal/tracing";
import { getMeter, startSpan, trace } from "@internal/tracing";
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
import { serializeTraceparent } from "@trigger.dev/core/v3/isomorphic";
import type {
AttemptFailedSpanEvent,
CancellationSpanEvent,
ExceptionSpanEvent,
OtherSpanEvent,
SpanEvents,
TaskEventStyle,
TaskRunError,
} from "@trigger.dev/core/v3/schemas";
import {
isAttemptFailedSpanEvent,
isCancellationSpanEvent,
isExceptionSpanEvent,
PRIMARY_VARIANT,
} from "@trigger.dev/core/v3/schemas";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3/semanticInternalAttributes";
import { unflattenAttributes } from "@trigger.dev/core/v3/utils/flattenAttributes";
import type { TaskEventLevel } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
import { DynamicFlushScheduler } from "../dynamicFlushScheduler.server";
import { tracePubSub } from "../services/tracePubSub.server";
import type { TaskEventStoreTable } from "../taskEventStore.server";
import {
calculateDurationFromStart,
calculateDurationFromStartJsDate,
convertDateToNanoseconds,
createExceptionPropertiesFromError,
extractContextFromCarrier,
generateDeterministicSpanId,
generateSpanId,
generateTraceId,
getNowInNanoseconds,
parseEventsField,
removePrivateProperties,
} from "./common.server";
import type {
CompleteableTaskRun,
CreateEventInput,
EventBuilder,
IEventRepository,
RunPreparedEvent,
SpanDetail,
SpanDetailedSummary,
SpanOverride,
SpanSummary,
SpanSummaryCommon,
StreamedTraceEvent,
TraceAttributes,
TraceDetailedSummary,
TraceEventOptions,
TraceSummary,
} from "./eventRepository.types";
import {
isClickHouseJsonParseError,
parseRowNumberFromError,
sanitizeRows,
} from "./sanitizeRowsOnParseError.server";
export type ClickhouseEventRepositoryConfig = {
clickhouse: ClickHouse;
batchSize?: number;
flushInterval?: number;
insertStrategy?: "insert" | "insert_async";
waitForAsyncInsert?: boolean;
asyncInsertMaxDataSize?: number;
asyncInsertBusyTimeoutMs?: number;
tracer?: Tracer;
maximumTraceSummaryViewCount?: number;
maximumTraceDetailedSummaryViewCount?: number;
maximumLiveReloadingSetting?: number;
/**
* Maximum age in milliseconds for start_time. If start_time is older than this threshold,
* it will be clamped to the current time when creating events.
* If not provided, no clamping will be done.
*/
startTimeMaxAgeMs?: number;
/**
* The version of the ClickHouse task_events table to use.
* - "v1": Uses task_events_v1 (partitioned by start_time)
* - "v2": Uses task_events_v2 (partitioned by inserted_at to avoid "too many parts" errors)
*/
version?: "v1" | "v2";
/** LLM metrics flush scheduler config */
llmMetricsBatchSize?: number;
llmMetricsFlushInterval?: number;
llmMetricsMaxBatchSize?: number;
llmMetricsMaxConcurrency?: number;
/** OTLP / task metrics_v1 flush scheduler config */
otlpMetricsBatchSize?: number;
otlpMetricsFlushInterval?: number;
otlpMetricsMaxConcurrency?: number;
/** Inject a meter for self-observability; defaults to the global provider. */
meter?: Meter;
};
/**
* ClickHouse-based implementation of the EventRepository.
* This implementation stores events in ClickHouse for better analytics and performance.
*/
export class ClickhouseEventRepository implements IEventRepository {
private _clickhouse: ClickHouse;
private _config: ClickhouseEventRepositoryConfig;
private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input | TaskEventV2Input>;
private readonly _llmMetricsFlushScheduler: DynamicFlushScheduler<LlmMetricsV1Input>;
private readonly _otlpMetricsFlushScheduler: DynamicFlushScheduler<MetricsV1Input>;
private _tracer: Tracer;
private _version: "v1" | "v2";
/**
* Counts batches that hit a ClickHouse JSON parse failure that survived
* one sanitize-retry. These batches are dropped on the floor (the scheduler
* is told the flush "succeeded" so its queue counter doesn't leak), and we
* track the drop count for observability.
*/
private _permanentlyDroppedBatches = 0;
private readonly _droppedBatchesCounter: Counter;
constructor(config: ClickhouseEventRepositoryConfig) {
this._clickhouse = config.clickhouse;
this._config = config;
this._tracer = config.tracer ?? trace.getTracer("clickhouseEventRepo", "0.0.1");
this._version = config.version ?? "v1";
const meter = config.meter ?? getMeter("ingest-flush");
this._droppedBatchesCounter = meter.createCounter("ingest.flush.batches_dropped", {
description: "Batches permanently dropped after an unrecoverable ClickHouse JSON parse error",
unit: "batches",
});
this._flushScheduler = new DynamicFlushScheduler({
name: `task_events_${this._version}`,
batchSize: config.batchSize ?? 1000,
flushInterval: config.flushInterval ?? 1000,
callback: this.#flushBatch.bind(this),
minConcurrency: 1,
maxConcurrency: 10,
maxBatchSize: 10000,
memoryPressureThreshold: 10000,
loadSheddingThreshold: 10000,
loadSheddingEnabled: false,
isDroppableEvent: (event: TaskEventV1Input | TaskEventV2Input) => {
// Only drop LOG events during load shedding
return event.kind === "DEBUG_EVENT";
},
});
this._llmMetricsFlushScheduler = new DynamicFlushScheduler({
name: "llm_metrics",
batchSize: config.llmMetricsBatchSize ?? 5000,
flushInterval: config.llmMetricsFlushInterval ?? 2000,
callback: this.#flushLlmMetricsBatch.bind(this),
minConcurrency: 1,
maxConcurrency: config.llmMetricsMaxConcurrency ?? 2,
maxBatchSize: config.llmMetricsMaxBatchSize ?? 10000,
memoryPressureThreshold: config.llmMetricsMaxBatchSize ?? 10000,
loadSheddingEnabled: false,
});
this._otlpMetricsFlushScheduler = new DynamicFlushScheduler({
name: "otlp_metrics",
batchSize: config.otlpMetricsBatchSize ?? 10000,
flushInterval: config.otlpMetricsFlushInterval ?? 1000,
callback: this.#flushOtelMetricsBatch.bind(this),
minConcurrency: 1,
maxConcurrency: config.otlpMetricsMaxConcurrency ?? 3,
loadSheddingEnabled: false,
});
}
get version() {
return this._version;
}
get maximumLiveReloadingSetting() {
return this._config.maximumLiveReloadingSetting ?? 1000;
}
/** Exposed for tests and metrics — total batches lost to unrecoverable parse errors. */
get permanentlyDroppedBatches() {
return this._permanentlyDroppedBatches;
}
/**
* Clamps a start time (in nanoseconds) to now if it's too far in the past.
* Returns the clamped value as a bigint.
*/
#clampStartTimeNanoseconds(startTimeNs: bigint): bigint {
if (!this._config.startTimeMaxAgeMs) {
return startTimeNs;
}
const nowNs = getNowInNanoseconds();
const maxAgeNs = BigInt(this._config.startTimeMaxAgeMs) * 1_000_000n; // ms to ns
const minAllowedStartTime = nowNs - maxAgeNs;
if (startTimeNs < minAllowedStartTime) {
return nowNs;
}
return startTimeNs;
}
/**
* Clamps a start time string (nanoseconds as string) to now if it's too far in the past.
* Returns the formatted string for ClickHouse.
*/
#clampAndFormatStartTime(startTimeNsString: string): string {
const startTimeNs = BigInt(startTimeNsString);
const clampedNs = this.#clampStartTimeNanoseconds(startTimeNs);
return formatClickhouseDate64NanosecondsEpochString(clampedNs.toString());
}
/**
* Clamps a Date start time to now if it's too far in the past.
*/
#clampStartTimeDate(startTime: Date): Date {
if (!this._config.startTimeMaxAgeMs) {
return startTime;
}
const now = new Date();
const minAllowedStartTime = new Date(now.getTime() - this._config.startTimeMaxAgeMs);
if (startTime < minAllowedStartTime) {
return now;
}
return startTime;
}
async #flushBatch(flushId: string, events: (TaskEventV1Input | TaskEventV2Input)[]) {
await startSpan(this._tracer, "flushBatch", async (span) => {
span.setAttribute("flush_id", flushId);
span.setAttribute("event_count", events.length);
span.setAttribute("version", this._version);
const firstEvent = events[0];
if (firstEvent) {
logger.debug("ClickhouseEventRepository.flushBatch first event", {
event: firstEvent,
version: this._version,
});
}
const insertFn =
this._version === "v2"
? this._clickhouse.taskEventsV2.insert
: this._clickhouse.taskEvents.insert;
const doInsert = async () => {
const [insertError, insertResult] = await insertFn(events, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
});
if (insertError) throw insertError;
return insertResult;
};
const outcome = await this.#insertWithJsonParseRecovery(
flushId,
events,
doInsert,
`task_events_${this._version}`
);
if (outcome.kind === "dropped") {
// Loud log already emitted; nothing landed in ClickHouse — don't publish to Redis.
return;
}
logger.debug("ClickhouseEventRepository.flushBatch Inserted batch into clickhouse", {
events: events.length,
insertResult: outcome.insertResult,
sanitized: outcome.kind === "sanitized",
version: this._version,
});
this.#publishToRedis(events);
});
}
async #flushLlmMetricsBatch(flushId: string, rows: LlmMetricsV1Input[]) {
const doInsert = async () => {
const [insertError, insertResult] = await this._clickhouse.llmMetrics.insert(rows, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
});
if (insertError) throw insertError;
return insertResult;
};
const outcome = await this.#insertWithJsonParseRecovery(
flushId,
rows,
doInsert,
"llm_metrics_v1"
);
if (outcome.kind === "dropped") {
return;
}
logger.debug("ClickhouseEventRepository.flushLlmMetricsBatch Inserted LLM metrics batch", {
rows: rows.length,
sanitized: outcome.kind === "sanitized",
});
}
/**
* Wraps a ClickHouse insert callable with reactive UTF-16 sanitization.
*
* On a `Cannot parse JSON object` failure:
* 1. Sanitize the batch from `max(0, parsedRowN - 1)` onwards (rows
* before the failing one parsed fine — known good).
* 2. Retry the insert once with the sanitized batch.
* 3. If the retry still fails with the same error class, log loudly,
* increment `permanentlyDroppedBatches`, and return without
* throwing — the scheduler's transient-retry path would just repeat
* the same deterministic failure.
*
* Non-parse errors propagate unchanged so the scheduler's existing
* backoff/retry behaviour still handles transient network or CH issues.
*/
async #insertWithJsonParseRecovery<T extends object>(
flushId: string,
rows: T[],
doInsert: () => Promise<unknown>,
contextLabel: string
): Promise<
| { kind: "inserted"; insertResult: unknown }
| { kind: "sanitized"; insertResult: unknown }
| { kind: "dropped" }
> {
try {
return { kind: "inserted", insertResult: await doInsert() };
} catch (firstError) {
if (!isClickHouseJsonParseError(firstError)) throw firstError;
const firstMessage =
typeof firstError === "object" && firstError !== null && "message" in firstError
? String((firstError as { message?: unknown }).message ?? "")
: String(firstError);
// Sanitize the whole batch. ClickHouse's `at row N` index is logged
// for observability but not used to slice — its semantics under
// parallel parsing are not stable enough to safely skip rows.
const rowHint = parseRowNumberFromError(firstMessage);
const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);
// Sanitizer found nothing to fix → retrying the exact same batch is
// guaranteed to hit the same deterministic parse failure. Skip the
// wasted ClickHouse round-trip and drop loudly. Throwing instead would
// hand the failure back to the scheduler's 3× transient-retry loop —
// exactly the retry storm this wrapper is designed to avoid.
if (fieldsSanitized === 0) {
this._permanentlyDroppedBatches += 1;
this._droppedBatchesCounter.add(1, { table: contextLabel });
logger.error(
"Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
{
flushId,
contextLabel,
batchSize: rows.length,
clickhouseRowHint: rowHint,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
clickhouseError: firstMessage.split("\n")[0],
}
);
return { kind: "dropped" };
}
logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
flushId,
contextLabel,
batchSize: rows.length,
clickhouseRowHint: rowHint,
rowsTouched,
fieldsSanitized,
clickhouseError: firstMessage.split("\n")[0],
});
try {
return { kind: "sanitized", insertResult: await doInsert() };
} catch (retryError) {
if (!isClickHouseJsonParseError(retryError)) throw retryError;
this._permanentlyDroppedBatches += 1;
this._droppedBatchesCounter.add(1, { table: contextLabel });
const retryMessage =
typeof retryError === "object" && retryError !== null && "message" in retryError
? String((retryError as { message?: unknown }).message ?? "")
: String(retryError);
logger.error("Dropped batch after sanitize-retry still hit ClickHouse JSON parse error", {
flushId,
contextLabel,
batchSize: rows.length,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
firstError: firstMessage.split("\n")[0],
retryError: retryMessage.split("\n")[0],
});
return { kind: "dropped" };
}
}
}
async #flushOtelMetricsBatch(flushId: string, rows: MetricsV1Input[]) {
await startSpan(this._tracer, "flushOtelMetricsBatch", async (span) => {
span.setAttribute("flush_id", flushId);
span.setAttribute("row_count", rows.length);
const [insertError] = await this._clickhouse.metrics.insert(rows, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
});
if (insertError) {
throw insertError;
}
logger.debug("ClickhouseEventRepository.flushOtelMetricsBatch Inserted OTLP metrics batch", {
rows: rows.length,
});
});
}
#createLlmMetricsInput(event: CreateEventInput): LlmMetricsV1Input {
const llmMetrics = event._llmMetrics!;
return {
organization_id: event.organizationId,
project_id: event.projectId,
environment_id: event.environmentId,
run_id: event.runId,
task_identifier: event.taskSlug,
trace_id: event.traceId,
span_id: event.spanId,
gen_ai_system: llmMetrics.genAiSystem,
request_model: llmMetrics.requestModel,
response_model: llmMetrics.responseModel,
base_response_model: llmMetrics.baseResponseModel,
matched_model_id: llmMetrics.matchedModelId,
operation_id: llmMetrics.operationId,
finish_reason: llmMetrics.finishReason,
cost_source: llmMetrics.costSource,
pricing_tier_id: llmMetrics.pricingTierId,
pricing_tier_name: llmMetrics.pricingTierName,
input_tokens: llmMetrics.inputTokens,
output_tokens: llmMetrics.outputTokens,
total_tokens: llmMetrics.totalTokens,
usage_details: llmMetrics.usageDetails,
input_cost: llmMetrics.inputCost,
output_cost: llmMetrics.outputCost,
total_cost: llmMetrics.totalCost,
cost_details: llmMetrics.costDetails,
provider_cost: llmMetrics.providerCost,
ms_to_first_chunk: llmMetrics.msToFirstChunk,
tokens_per_second: llmMetrics.tokensPerSecond,
metadata: llmMetrics.metadata,
prompt_slug: llmMetrics.promptSlug,
prompt_version: llmMetrics.promptVersion,
start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
};
}
#getClickhouseInsertSettings() {
if (this._config.insertStrategy === "insert") {
return {};
} else {
return {
async_insert: 1 as const,
async_insert_max_data_size: this._config.asyncInsertMaxDataSize?.toString() ?? "10485760",
async_insert_busy_timeout_ms: this._config.asyncInsertBusyTimeoutMs ?? 5000,
wait_for_async_insert: this._config.waitForAsyncInsert ? (1 as const) : (0 as const),
};
}
}
async #publishToRedis(events: (TaskEventV1Input | TaskEventV2Input)[]) {
if (events.length === 0) return;
await tracePubSub.publish(events.map((e) => e.trace_id));
}
insertMany(events: CreateEventInput[]): void {
this.addToBatch(events.flatMap((event) => this.createEventToTaskEventV1Input(event)));
// Dual-write LLM metrics records for spans with cost enrichment
const llmMetricsRows = events
.filter((e) => e._llmMetrics != null)
.map((e) => this.#createLlmMetricsInput(e));
if (llmMetricsRows.length > 0) {
this._llmMetricsFlushScheduler.addToBatch(llmMetricsRows);
}
}
async insertManyImmediate(events: CreateEventInput[]): Promise<void> {
this.insertMany(events);
}
insertManyMetrics(rows: MetricsV1Input[]): void {
if (rows.length === 0) return;
this._otlpMetricsFlushScheduler.addToBatch(rows);
}
private createEventToTaskEventV1Input(event: CreateEventInput): TaskEventV1Input[] {
return [
{
environment_id: event.environmentId,
organization_id: event.organizationId,
project_id: event.projectId,
task_identifier: event.taskSlug,
run_id: event.runId,
start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
trace_id: event.traceId,
span_id: event.spanId,
parent_span_id: event.parentId ?? "",
message: event.message,
kind: this.createEventToTaskEventV1InputKind(event),
status: this.createEventToTaskEventV1InputStatus(event),
attributes: this.createEventToTaskEventV1InputAttributes(
event.properties,
event.resourceProperties
),
metadata: this.createEventToTaskEventV1InputMetadata(event),
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
),
machine_id: event.machineId ?? "",
},
...this.spanEventsToTaskEventV1Input(event),
];
}
private spanEventsToTaskEventV1Input(event: CreateEventInput): TaskEventV1Input[] {
if (!event.events) return [];
const spanEvents = parseEventsField(event.events);
const records = spanEvents.map((e) => this.createTaskEventV1InputFromSpanEvent(e, event));
if (event.isPartial) {
return records;
}
// Only return events where the event start_time is greater than the span start_time
return records.filter(
(r) =>
convertClickhouseDate64NanosecondsEpochStringToBigInt(r.start_time) >
BigInt(event.startTime)
);
}
private createTaskEventV1InputFromSpanEvent(
spanEvent: SpanEvents[number],
event: CreateEventInput
): TaskEventV1Input {
if (isExceptionSpanEvent(spanEvent)) {
return this.createTaskEventV1InputFromExceptionEvent(spanEvent, event);
}
if (isCancellationSpanEvent(spanEvent)) {
return this.createTaskEventV1InputFromCancellationEvent(spanEvent, event);
}
if (isAttemptFailedSpanEvent(spanEvent)) {
return this.createTaskEventV1InputFromAttemptFailedEvent(spanEvent, event);
}
return this.createTaskEventV1InputFromOtherEvent(spanEvent, event);
}
private createTaskEventV1InputFromExceptionEvent(
spanEvent: ExceptionSpanEvent,
event: CreateEventInput
): TaskEventV1Input {
return {
environment_id: event.environmentId,
organization_id: event.organizationId,
project_id: event.projectId,
task_identifier: event.taskSlug,
run_id: event.runId,
start_time: this.#clampAndFormatStartTime(
convertDateToNanoseconds(spanEvent.time).toString()
),
duration: "0", // Events have no duration
trace_id: event.traceId,
span_id: event.spanId,
parent_span_id: event.parentId ?? "",
message: spanEvent.name,
kind: "SPAN_EVENT",
status: "ERROR",
attributes: {
error: {
message: spanEvent.properties.exception.message,
name: spanEvent.properties.exception.type,
stackTrace: spanEvent.properties.exception.stacktrace,
},
},
metadata: JSON.stringify({
exception: spanEvent.properties.exception,
}), // Events have no metadata
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
),
};
}
private createTaskEventV1InputFromCancellationEvent(
spanEvent: CancellationSpanEvent,
event: CreateEventInput
): TaskEventV1Input {
return {
environment_id: event.environmentId,
organization_id: event.organizationId,
project_id: event.projectId,
task_identifier: event.taskSlug,
run_id: event.runId,
start_time: this.#clampAndFormatStartTime(
convertDateToNanoseconds(spanEvent.time).toString()
),
duration: "0", // Events have no duration
trace_id: event.traceId,
span_id: event.spanId,
parent_span_id: event.parentId ?? "",
message: spanEvent.name,
kind: "SPAN_EVENT",
status: "CANCELLED",
attributes: {},
metadata: JSON.stringify({
reason: spanEvent.properties.reason,
}), // Events have no metadata
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
),
};
}
private createTaskEventV1InputFromAttemptFailedEvent(
spanEvent: AttemptFailedSpanEvent,
event: CreateEventInput
): TaskEventV1Input {
return {
environment_id: event.environmentId,
organization_id: event.organizationId,
project_id: event.projectId,
task_identifier: event.taskSlug,
run_id: event.runId,
start_time: this.#clampAndFormatStartTime(
convertDateToNanoseconds(spanEvent.time).toString()
),
duration: "0", // Events have no duration
trace_id: event.traceId,
span_id: event.spanId,
parent_span_id: event.parentId ?? "",
message: spanEvent.name,
kind: "ANCESTOR_OVERRIDE",
status: "OK",
attributes: {
error: {
message: spanEvent.properties.exception.message,
name: spanEvent.properties.exception.type,
stackTrace: spanEvent.properties.exception.stacktrace,
},
},
metadata: JSON.stringify(spanEvent.properties),
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
),
};
}
private createTaskEventV1InputFromOtherEvent(
spanEvent: OtherSpanEvent,
event: CreateEventInput
): TaskEventV1Input {
return {
environment_id: event.environmentId,
organization_id: event.organizationId,
project_id: event.projectId,
task_identifier: event.taskSlug,
run_id: event.runId,
start_time: this.#clampAndFormatStartTime(
convertDateToNanoseconds(spanEvent.time).toString()
),
duration: "0", // Events have no duration
trace_id: event.traceId,
span_id: event.spanId,
parent_span_id: event.parentId ?? "",
message: spanEvent.name,
kind: "SPAN_EVENT",
status: "OK",
attributes: {},
metadata: JSON.stringify(unflattenAttributes(spanEvent.properties as Attributes)),
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
),
};
}
private createEventToTaskEventV1InputKind(event: CreateEventInput): string {
if (event.kind === "UNSPECIFIED") {
return "ANCESTOR_OVERRIDE";
}
if (event.level === "TRACE") {
return "SPAN";
}
if (event.isDebug) {
return "DEBUG_EVENT";
}
return `LOG_${(event.level ?? "LOG").toUpperCase()}`;
}
private createEventToTaskEventV1InputStatus(event: CreateEventInput): string {
if (event.isPartial) {
return "PARTIAL";
}
if (event.isError) {
return "ERROR";
}
if (event.isCancelled) {
return "CANCELLED";
}
return "OK";
}
private createEventToTaskEventV1InputAttributes(
attributes: Attributes,
resourceAttributes?: Attributes
): Record<string, unknown> {
if (!attributes && !resourceAttributes) {
return {};
}
return {
...this.createAttributesToInputAttributes(attributes),
...this.createAttributesToInputAttributes(resourceAttributes, "$resource"),
};
}
private createAttributesToInputAttributes(
attributes: Attributes | undefined,
key?: string
): Record<string, unknown> {
if (!attributes) {
return {};
}
const publicAttributes = removePrivateProperties(attributes);
if (!publicAttributes) {
return {};
}
const unflattenedAttributes = unflattenAttributes(publicAttributes);
if (unflattenedAttributes && typeof unflattenedAttributes === "object") {
if (key) {
return {
[key]: unflattenedAttributes,
};
}
return {
...unflattenedAttributes,
};
}
return {};
}
private createEventToTaskEventV1InputMetadata(event: CreateEventInput): string {
return JSON.stringify({
style: event.style ? unflattenAttributes(event.style) : undefined,
attemptNumber: event.attemptNumber,
entity: this.extractEntityFromAttributes(event.properties),
});
}
private extractEntityFromAttributes(
attributes: Attributes
): { entityType: string; entityId?: string; entityMetadata?: string } | undefined {
if (!attributes || typeof attributes !== "object") {
return undefined;
}
const entityType = attributes[SemanticInternalAttributes.ENTITY_TYPE];
const entityId = attributes[SemanticInternalAttributes.ENTITY_ID];
const entityMetadata = attributes[SemanticInternalAttributes.ENTITY_METADATA];
if (typeof entityType !== "string") {
return undefined;
}
return {
entityType,
entityId: entityId as string | undefined,
entityMetadata: entityMetadata as string | undefined,
};
}
private addToBatch(events: TaskEventV1Input[] | TaskEventV1Input) {
this._flushScheduler.addToBatch(Array.isArray(events) ? events : [events]);
}
// Event recording methods
async recordEvent(
message: string,
options: TraceEventOptions & { duration?: number; parentId?: string }
): Promise<void> {
const propagatedContext = extractContextFromCarrier(options.context ?? {});
const startTime = options.startTime ?? getNowInNanoseconds();
const duration =
options.duration ??
(options.endTime
? calculateDurationFromStart(startTime, options.endTime, 100 * 1_000_000)
: 100);
const traceId = propagatedContext?.traceparent?.traceId ?? generateTraceId();
const parentId = options.parentId ?? propagatedContext?.traceparent?.spanId;
const spanId = options.spanIdSeed
? generateDeterministicSpanId(traceId, options.spanIdSeed)
: generateSpanId();
if (!options.attributes.runId) {
throw new Error("runId is required");
}
const kind = options.attributes.isDebug ? "DEBUG_EVENT" : "SPAN";
const metadata = {
style: {
icon: options.attributes.isDebug ? "warn" : "play",
},
...options.attributes.metadata,
};
const event: TaskEventV1Input = {
environment_id: options.environment.id,
organization_id: options.environment.organizationId,
project_id: options.environment.projectId,
task_identifier: options.taskSlug,
run_id: options.attributes.runId,
start_time: this.#clampAndFormatStartTime(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(duration),
trace_id: traceId,
span_id: spanId,
parent_span_id: parentId ?? "",
message,
kind,
status: "OK",
attributes: options.attributes.properties
? this.createEventToTaskEventV1InputAttributes(options.attributes.properties)
: undefined,
metadata: JSON.stringify(metadata),
// TODO: make sure configurable and by org
expires_at: convertDateToClickhouseDateTime(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)),
};
this._flushScheduler.addToBatch([event]);
}
async traceEvent<TResult>(
message: string,
options: TraceEventOptions & { incomplete?: boolean; isError?: boolean },
callback: (
e: EventBuilder,
traceContext: Record<string, string | undefined>,
traceparent?: { traceId: string; spanId: string }
) => Promise<TResult>
): Promise<TResult> {
const propagatedContext = extractContextFromCarrier(options.context ?? {});
const start = process.hrtime.bigint();
const startTime = options.startTime ?? getNowInNanoseconds();
const traceId = options.spanParentAsLink
? generateTraceId()
: (propagatedContext?.traceparent?.traceId ?? generateTraceId());
const parentId = options.spanParentAsLink ? undefined : propagatedContext?.traceparent?.spanId;
const spanId = options.spanIdSeed
? generateDeterministicSpanId(traceId, options.spanIdSeed)
: generateSpanId();
const traceContext = {
...options.context,
traceparent: serializeTraceparent(traceId, spanId),
};
let isStopped = false;
let failedWithError: TaskRunError | undefined;
const eventBuilder = {
traceId,
spanId,
setAttribute: (key: keyof TraceAttributes, value: TraceAttributes[keyof TraceAttributes]) => {
if (value) {
// We need to merge the attributes with the existing attributes
const existingValue = options.attributes[key];
if (existingValue && typeof existingValue === "object" && typeof value === "object") {
// @ts-ignore
options.attributes[key] = { ...existingValue, ...value };
} else {
// @ts-ignore
options.attributes[key] = value;
}
}
},
stop: () => {
isStopped = true;
},
failWithError: (error: TaskRunError) => {
failedWithError = error;
},
};
const result = await callback(eventBuilder, traceContext, propagatedContext?.traceparent);
if (isStopped) {
return result;
}
const duration = process.hrtime.bigint() - start;
if (!options.attributes.runId) {
throw new Error("runId is required");
}
const metadata = {
style: {
icon: "task",
variant: PRIMARY_VARIANT,
...options.attributes.style,
},
...options.attributes.metadata,
};
const event: TaskEventV1Input = {
environment_id: options.environment.id,
organization_id: options.environment.organizationId,
project_id: options.environment.projectId,
task_identifier: options.taskSlug,
run_id: options.attributes.runId,
start_time: this.#clampAndFormatStartTime(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
trace_id: traceId,
span_id: spanId,
parent_span_id: parentId ?? "",
message,
kind: "SPAN",
status: failedWithError ? "ERROR" : options.incomplete ? "PARTIAL" : "OK",
attributes: options.attributes.properties
? this.createEventToTaskEventV1InputAttributes(options.attributes.properties)
: {},
metadata: JSON.stringify(metadata),
// TODO: make sure configurable and by org
expires_at: convertDateToClickhouseDateTime(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)),
};
const events = [event];
if (failedWithError) {
const error = createJsonErrorObject(failedWithError);
events.push({
environment_id: options.environment.id,
organization_id: options.environment.organizationId,
project_id: options.environment.projectId,
task_identifier: options.taskSlug,
run_id: options.attributes.runId,
start_time: this.#clampAndFormatStartTime(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
trace_id: traceId,
span_id: spanId,
parent_span_id: parentId ?? "",
message: "exception",
kind: "SPAN_EVENT",
status: "ERROR",
attributes: {
error,
},
metadata: JSON.stringify({
exception: createExceptionPropertiesFromError(failedWithError),
}),
// TODO: make sure configurable and by org
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
),
});
}
this._flushScheduler.addToBatch(events);
return result;
}
// Run event completion methods
async completeSuccessfulRunEvent({
run,
endTime,
}: {
run: CompleteableTaskRun;
endTime?: Date;
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
const startTime = convertDateToNanoseconds(clampedCreatedAt);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: run.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(
calculateDurationFromStart(startTime, endTime ?? new Date())
),
trace_id: run.traceId,
span_id: run.spanId,
parent_span_id: run.parentSpanId ?? "",
message: run.taskIdentifier,
kind: "SPAN",
status: "OK",
attributes: {},
metadata: "{}",
expires_at: expiresAt,
};
this.addToBatch(event);
}
async completeCachedRunEvent({
run,
blockedRun,
spanId,
parentSpanId,
spanCreatedAt,
isError,
endTime,
}: {
run: CompleteableTaskRun;
blockedRun: CompleteableTaskRun;
spanId: string;
parentSpanId: string;
spanCreatedAt: Date;
isError: boolean;
endTime?: Date;
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedSpanCreatedAt = this.#clampStartTimeDate(spanCreatedAt);
const startTime = convertDateToNanoseconds(clampedSpanCreatedAt);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: blockedRun.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(
calculateDurationFromStart(startTime, endTime ?? new Date())
),
trace_id: blockedRun.traceId,
span_id: spanId,
parent_span_id: parentSpanId,
message: run.taskIdentifier,
kind: "SPAN",
status: isError ? "ERROR" : "OK",
attributes: {},
metadata: "{}",
expires_at: expiresAt,
};
this.addToBatch(event);
}
async completeFailedRunEvent({
run,
endTime,
exception,
}: {
run: CompleteableTaskRun;
endTime?: Date;
exception: { message?: string; type?: string; stacktrace?: string };
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
const startTime = convertDateToNanoseconds(clampedCreatedAt);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: run.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(
calculateDurationFromStart(startTime, endTime ?? new Date())
),
trace_id: run.traceId,
span_id: run.spanId,
parent_span_id: run.parentSpanId ?? "",
message: run.taskIdentifier,
kind: "SPAN",
status: "ERROR",
attributes: {
error: {
name: exception.type,
message: exception.message,
stackTrace: exception.stacktrace,
},
},
metadata: "{}",
expires_at: expiresAt,
};
this.addToBatch(event);
}
async completeExpiredRunEvent({
run,
endTime,
ttl,
}: {
run: CompleteableTaskRun;
endTime?: Date;
ttl: string;
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
const startTime = convertDateToNanoseconds(clampedCreatedAt);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: run.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(
calculateDurationFromStart(startTime, endTime ?? new Date())
),
trace_id: run.traceId,
span_id: run.spanId,
parent_span_id: run.parentSpanId ?? "",
message: run.taskIdentifier,
kind: "SPAN",
status: "ERROR",
attributes: {
error: {
message: `Run expired because the TTL (${ttl}) was reached`,
},
},
metadata: "{}",
expires_at: expiresAt,
};
this.addToBatch(event);
}
async createAttemptFailedRunEvent({
run,
endTime,
attemptNumber,
exception,
}: {
run: CompleteableTaskRun;
endTime?: Date;
attemptNumber: number;
exception: { message?: string; type?: string; stacktrace?: string };
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedEndTime = this.#clampStartTimeDate(endTime ?? new Date());
const startTime = convertDateToNanoseconds(clampedEndTime);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: run.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: "0",
trace_id: run.traceId,
span_id: run.spanId,
parent_span_id: run.parentSpanId ?? "",
message: "attempt_failed",
kind: "ANCESTOR_OVERRIDE",
status: "OK",
attributes: {},
metadata: JSON.stringify({
exception,
attemptNumber,
runId: run.friendlyId,
}),
expires_at: expiresAt,
};
this.addToBatch(event);
}
async cancelRunEvent({
reason,
run,
cancelledAt,
}: {
reason: string;
run: CompleteableTaskRun;
cancelledAt: Date;
}): Promise<void> {
if (!run.organizationId) {
return;
}
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
const startTime = convertDateToNanoseconds(clampedCreatedAt);
const expiresAt = convertDateToClickhouseDateTime(
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
);
const event: TaskEventV1Input = {
environment_id: run.runtimeEnvironmentId,
organization_id: run.organizationId,
project_id: run.projectId,
task_identifier: run.taskIdentifier,
run_id: run.friendlyId,
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
duration: formatClickhouseUnsignedIntegerString(
calculateDurationFromStart(startTime, cancelledAt)
),
trace_id: run.traceId,
span_id: run.spanId,
parent_span_id: run.parentSpanId ?? "",
message: run.taskIdentifier,
kind: "SPAN",
status: "CANCELLED",
attributes: {},
metadata: JSON.stringify({
reason,
}),
expires_at: expiresAt,
};
this.addToBatch(event);
}
// Query methods
async getTraceSummary(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): Promise<TraceSummary | undefined> {
const limit = this._config.maximumTraceSummaryViewCount;
const records = await this.#fetchTraceSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
limit,
});
if (!records) {
return;
}
const summary = this.#buildTraceSummaryFromRecords(records);
if (!summary) {
return;
}
return {
...summary,
isTruncated: limit !== undefined && records.length >= limit,
};
}
async getTraceSubtreeSummary(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
anchorSpanId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): Promise<TraceSummary | undefined> {
const { records, isTruncated, missingAnchor } = await this.#fetchTraceSubtreeRecords({
environmentId,
traceId,
anchorSpanId,
startCreatedAt,
endCreatedAt,
options,
limit: this._config.maximumTraceSummaryViewCount,
});
if (missingAnchor) {
return;
}
const summary = this.#buildTraceSummaryFromRecords(records, {
rootSpanId: anchorSpanId,
});
if (!summary) {
return;
}
return {
...summary,
isTruncated,
};
}
async #fetchTraceSubtreeRecords({
environmentId,
traceId,
anchorSpanId,
startCreatedAt,
endCreatedAt,
options,
limit: maxRows,
}: {
environmentId: string;
traceId: string;
anchorSpanId: string;
startCreatedAt: Date;
endCreatedAt?: Date;
options?: { includeDebugLogs?: boolean };
limit?: number;
}): Promise<{
records: TaskEventSummaryV1Result[];
isTruncated: boolean;
missingAnchor: boolean;
}> {
return this.#collectTraceSubtreeRecords({
anchorSpanId,
maxRows,
// Ancestors are fetched by explicit spanIds and start before the anchor
// run's time window, so applying startCreatedAt would wrongly exclude them
// (and with it the cancellation/error overrides they propagate downward).
fetchAncestor: (batch) =>
this.#fetchTraceSummaryRecords({
environmentId,
traceId,
skipTimeWindow: true,
options,
...batch,
}),
fetchDescendant: (batch) =>
this.#fetchTraceSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
...batch,
}),
});
}
async #fetchTraceDetailedSubtreeRecords({
environmentId,
traceId,
anchorSpanId,
startCreatedAt,
endCreatedAt,
options,
limit: maxRows,
}: {
environmentId: string;
traceId: string;
anchorSpanId: string;
startCreatedAt: Date;
endCreatedAt?: Date;
options?: { includeDebugLogs?: boolean };
limit?: number;
}): Promise<{
records: TaskEventDetailedSummaryV1Result[];
isTruncated: boolean;
missingAnchor: boolean;
}> {
return this.#collectTraceSubtreeRecords({
anchorSpanId,
maxRows,
// Ancestors are fetched by explicit spanIds and start before the anchor
// run's time window, so applying startCreatedAt would wrongly exclude them
// (and with it the cancellation/error overrides they propagate downward).
fetchAncestor: (batch) =>
this.#fetchTraceDetailedSummaryRecords({
environmentId,
traceId,
skipTimeWindow: true,
options,
...batch,
}),
fetchDescendant: (batch) =>
this.#fetchTraceDetailedSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
...batch,
}),
});
}
async #collectTraceSubtreeRecords<T extends { span_id: string; parent_span_id: string }>({
anchorSpanId,
maxRows,
fetchAncestor,
fetchDescendant,
}: {
anchorSpanId: string;
maxRows?: number;
fetchAncestor: (batch: { spanIds: string[]; limit?: number }) => Promise<T[] | undefined>;
fetchDescendant: (batch: {
spanIds?: string[];
parentSpanIds?: string[];
limit?: number;
}) => Promise<T[] | undefined>;
}): Promise<{
records: T[];
isTruncated: boolean;
missingAnchor: boolean;
}> {
const allRecords: T[] = [];
const collectedSpanIds = new Set<string>();
let isTruncated = false;
const anchorRecords = await fetchDescendant({
spanIds: [anchorSpanId],
limit: maxRows,
});
if (!anchorRecords || anchorRecords.length === 0) {
return { records: [], isTruncated: false, missingAnchor: true };
}
if (maxRows && anchorRecords.length >= maxRows) {
isTruncated = true;
}
allRecords.push(...anchorRecords);
collectedSpanIds.add(anchorSpanId);
let parentSpanId = this.#parentSpanIdFromRecords(anchorRecords, anchorSpanId);
while (parentSpanId) {
if (collectedSpanIds.has(parentSpanId)) {
break;
}
if (maxRows && allRecords.length >= maxRows) {
isTruncated = true;
break;
}
const parentRecords = await fetchAncestor({
spanIds: [parentSpanId],
limit: maxRows ? maxRows - allRecords.length : undefined,
});
if (!parentRecords || parentRecords.length === 0) {
break;
}
allRecords.push(...parentRecords);
collectedSpanIds.add(parentSpanId);
parentSpanId = this.#parentSpanIdFromRecords(parentRecords, parentSpanId);
}
// Walk descendants level-by-level rather than fetching everything after the anchor in one
// windowed query. parent_span_id isn't in the sort key, so each level rescans roughly the same
// granules - but trace depth is small in practice and repeated granule reads stay cached. A
// single broad query would pull every span after the anchor (a superset of the subtree) and
// make the maxRows cap drop real subtree spans in favour of unrelated ones.
let frontier = [anchorSpanId];
while (frontier.length > 0) {
if (maxRows && allRecords.length >= maxRows) {
isTruncated = true;
break;
}
const remaining = maxRows ? maxRows - allRecords.length : undefined;
const childRecords = await fetchDescendant({
parentSpanIds: frontier,
limit: remaining,
});
if (!childRecords || childRecords.length === 0) {
break;
}
if (remaining !== undefined && childRecords.length >= remaining) {
isTruncated = true;
}
allRecords.push(...childRecords);
const nextFrontier: string[] = [];
for (const record of childRecords) {
if (!collectedSpanIds.has(record.span_id)) {
collectedSpanIds.add(record.span_id);
nextFrontier.push(record.span_id);
}
}
frontier = nextFrontier;
}
return {
records: allRecords,
isTruncated,
missingAnchor: false,
};
}
#parentSpanIdFromRecords(
records: Array<{ span_id: string; parent_span_id: string }>,
spanId: string
): string | undefined {
const parentSpanId = records.find((record) => record.span_id === spanId)?.parent_span_id;
return parentSpanId ? parentSpanId : undefined;
}
#createTraceSummaryQueryBuilder() {
return this._version === "v2"
? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
: this._clickhouse.taskEvents.traceSummaryQueryBuilder();
}
async #fetchTraceSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
spanIds,
parentSpanIds,
limit,
skipTimeWindow,
}: {
environmentId: string;
traceId: string;
startCreatedAt?: Date;
endCreatedAt?: Date;
options?: { includeDebugLogs?: boolean };
spanIds?: string[];
parentSpanIds?: string[];
limit?: number;
skipTimeWindow?: boolean;
}): Promise<TaskEventSummaryV1Result[] | undefined> {
const queryBuilder = this.#createTraceSummaryQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
if (!skipTimeWindow) {
if (!startCreatedAt) {
throw new Error("startCreatedAt is required when skipTimeWindow is false");
}
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000);
const endCreatedAtWithBuffer = endCreatedAt
? new Date(endCreatedAt.getTime() + 60_000)
: undefined;
queryBuilder.where("start_time >= {startCreatedAt: String}", {
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
});
if (endCreatedAtWithBuffer) {
queryBuilder.where("start_time <= {endCreatedAt: String}", {
endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(),
});
}
if (this._version === "v2") {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
});
}
}
if (options?.includeDebugLogs === false) {
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
}
if (spanIds && spanIds.length > 0) {
queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds });
}
if (parentSpanIds && parentSpanIds.length > 0) {
queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds });
}
queryBuilder.orderBy("start_time ASC");
if (limit) {
queryBuilder.limit(limit);
}
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
return records;
}
#buildTraceSummaryFromRecords(
records: TaskEventSummaryV1Result[],
options?: { rootSpanId?: string }
): TraceSummary | undefined {
if (records.length === 0) {
return;
}
const recordsGroupedBySpanId: Record<string, TaskEventSummaryV1Result[]> = {};
for (const record of records) {
if (!recordsGroupedBySpanId[record.span_id]) {
recordsGroupedBySpanId[record.span_id] = [];
}
recordsGroupedBySpanId[record.span_id].push(record);
}
const spanSummaries = new Map<string, SpanSummary>();
let rootSpanId: string | undefined = options?.rootSpanId;
const metadataCache = new Map<string, Record<string, unknown>>();
for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
const spanSummary = this.#mergeRecordsIntoSpanSummary(spanId, spanRecords, metadataCache);
if (!spanSummary) {
continue;
}
spanSummaries.set(spanId, spanSummary);
if (!rootSpanId && !spanSummary.parentId) {
rootSpanId = spanId;
}
}
if (!rootSpanId) {
return;
}
const spans = Array.from(spanSummaries.values());
const rootSpan = spanSummaries.get(rootSpanId);
if (!rootSpan) {
return;
}
const overridesBySpanId: Record<string, SpanOverride> = {};
const finalSpans = spans.map((span) => {
return this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
});
return {
rootSpan,
spans: finalSpans,
overridesBySpanId,
};
}
async getSpan(
storeTable: TaskEventStoreTable,
environmentId: string,
spanId: string,
traceId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): Promise<SpanDetail | undefined> {
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000);
const queryBuilder =
this._version === "v2"
? this._clickhouse.taskEventsV2.spanDetailsQueryBuilder()
: this._clickhouse.taskEvents.spanDetailsQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
queryBuilder.where("span_id = {spanId: String}", { spanId });
queryBuilder.where("start_time >= {startCreatedAt: String}", {
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
});
if (endCreatedAt) {
queryBuilder.where("start_time <= {endCreatedAt: String}", {
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
});
}
// For v2, add inserted_at filtering for partition pruning
if (this._version === "v2") {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
});
}
queryBuilder.orderBy("start_time ASC");
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (!records) {
return;
}
// Create temporary metadata cache for this query
const metadataCache = new Map<string, Record<string, unknown>>();
const span = this.#mergeRecordsIntoSpanDetail(spanId, records, metadataCache);
return span;
}
#mergeRecordsIntoSpanDetail(
spanId: string,
records: TaskEventDetailsV1Result[],
metadataCache: Map<string, Record<string, unknown>>
): SpanDetail | undefined {
if (records.length === 0) {
return undefined;
}
let span: SpanDetail | undefined;
let earliestStartTime: Date | undefined;
for (const record of records) {
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
// Track the earliest start time across all records
if (
record.kind !== "ANCESTOR_OVERRIDE" &&
record.kind !== "SPAN_EVENT" &&
(!earliestStartTime || recordStartTime < earliestStartTime)
) {
earliestStartTime = recordStartTime;
}
if (!span) {
span = {
spanId: spanId,
parentId: record.parent_span_id ? record.parent_span_id : null,
message: record.message,
isError: false,
isPartial: true, // Partial by default, can only be set to false
isCancelled: false,
level: kindToLevel(record.kind),
startTime: recordStartTime,
duration: typeof record.duration === "number" ? record.duration : Number(record.duration),
events: [],
style: {},
properties: undefined,
resourceProperties: undefined,
entity: {
type: undefined,
id: undefined,
metadata: undefined,
},
metadata: {},
};
}
if (isLogEvent(record.kind)) {
span.isPartial = false;
span.isCancelled = false;
span.isError = record.status === "ERROR";
}
const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);
if (record.kind === "SPAN_EVENT") {
// We need to add an event to the span
span.events.push({
name: record.message,
time: recordStartTime,
properties: parsedMetadata ?? {},
});
}
if (parsedMetadata && "style" in parsedMetadata && parsedMetadata.style) {
span.style = parsedMetadata.style as TaskEventStyle;
}
if (
parsedMetadata &&
"entity" in parsedMetadata &&
typeof parsedMetadata.entity === "object" &&
parsedMetadata.entity &&
"entityType" in parsedMetadata.entity &&
typeof parsedMetadata.entity.entityType === "string" &&
"entityId" in parsedMetadata.entity &&
typeof parsedMetadata.entity.entityId === "string"
) {
span.entity = {
id: parsedMetadata.entity.entityId,
type: parsedMetadata.entity.entityType,
metadata:
"entityMetadata" in parsedMetadata.entity &&
parsedMetadata.entity.entityMetadata &&
typeof parsedMetadata.entity.entityMetadata === "string"
? parsedMetadata.entity.entityMetadata
: undefined,
};
}
if (record.kind === "SPAN") {
// Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
span.message = record.message;
if (record.status === "ERROR") {
span.isError = true;
span.isPartial = false;
span.isCancelled = false;
} else if (record.status === "CANCELLED") {
span.isCancelled = true;
span.isPartial = false;
span.isError = false;
} else if (record.status === "OK") {
span.isPartial = false;
}
if (record.status !== "PARTIAL") {
span.duration =
typeof record.duration === "number" ? record.duration : Number(record.duration);
}
}
// Parse attributes from the first record that has them, then re-parse for the
// completed SPAN record. The completed record's attributes are a superset of the
// partial's (includes enriched trigger.llm.* cost data added during ingestion).
// This means at most 2x JSON.parse per span detail query, but only on this
// read path (span detail view), not on ingestion.
if (typeof record.attributes_text === "string") {
const shouldUpdate =
span.properties == null ||
(typeof span.properties === "object" && Object.keys(span.properties).length === 0) ||
(record.kind === "SPAN" && record.status !== "PARTIAL");
if (shouldUpdate) {
const parsedAttributes = this.#parseAttributes(record.attributes_text);
const resourceAttributes = parsedAttributes["$resource"];
delete parsedAttributes["$resource"];
span.properties = parsedAttributes;
span.resourceProperties = resourceAttributes as Record<string, unknown> | undefined;
}
}
}
// Always use the earliest start time found across all records
if (span && earliestStartTime) {
span.startTime = earliestStartTime;
}
return span;
}
#parseAttributes(attributes_text: string): Record<string, unknown> {
if (!attributes_text) {
return {};
}
return JSON.parse(attributes_text) as Record<string, unknown>;
}
#applyAncestorOverrides<TSpanSummary extends SpanSummaryCommon>(
span: TSpanSummary,
spansById: Map<string, TSpanSummary>,
overridesBySpanId: Record<string, SpanOverride>
): TSpanSummary {
if (span.data.level !== "TRACE") {
return span;
}
if (!span.data.isPartial) {
return span;
}
if (!span.parentId) {
return span;
}
// Now we need to walk the ancestors of the span by span.parentId
// The first ancestor that is a TRACE span that is "closed" we will use to override the span
let parentSpanId: string | undefined = span.parentId;
let overrideSpan: TSpanSummary | undefined;
while (parentSpanId) {
const parentSpan = spansById.get(parentSpanId);
if (!parentSpan) {
break;
}
if (parentSpan.data.level === "TRACE" && !parentSpan.data.isPartial) {
overrideSpan = parentSpan;
break;
}
parentSpanId = parentSpan.parentId;
}
if (overrideSpan) {
return this.#applyAncestorToSpan(span, overrideSpan, overridesBySpanId);
}
return span;
}
#applyAncestorToSpan<TSpanSummary extends SpanSummaryCommon>(
span: TSpanSummary,
overrideSpan: TSpanSummary,
overridesBySpanId: Record<string, SpanOverride>
): TSpanSummary {
if (overridesBySpanId[span.id]) {
return span;
}
let override: SpanOverride | undefined = undefined;
const overrideEndTime = calculateEndTimeFromStartTime(
overrideSpan.data.startTime,
overrideSpan.data.duration
);
if (overrideSpan.data.isCancelled) {
override = {
isCancelled: true,
duration: calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime),
};
span.data.isCancelled = true;
span.data.isPartial = false;
span.data.isError = false;
span.data.duration = calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime);
const cancellationEvent = overrideSpan.data.events.find(
(event) => event.name === "cancellation"
);
if (cancellationEvent) {
span.data.events.push(cancellationEvent);
override.events = [cancellationEvent];
}
}
if (overrideSpan.data.isError && span.data.attemptNumber) {
const attemptFailedEvent = overrideSpan.data.events.find(
(event) =>
event.name === "attempt_failed" &&
event.properties.attemptNumber === span.data.attemptNumber &&
event.properties.runId === span.runId
) as AttemptFailedSpanEvent | undefined;
if (attemptFailedEvent) {
const exceptionEvent = {
name: "exception",
time: attemptFailedEvent.time,
properties: {
exception: attemptFailedEvent.properties.exception,
},
} satisfies ExceptionSpanEvent;
span.data.isError = true;
span.data.isPartial = false;
span.data.isCancelled = false;
span.data.duration = calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime);
span.data.events.push(exceptionEvent);
span.data.events.push(attemptFailedEvent);
override = {
isError: true,
events: [exceptionEvent],
duration: calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime),
};
}
}
if (override) {
overridesBySpanId[span.id] = override;
}
return span;
}
#mergeRecordsIntoSpanSummary(
spanId: string,
records: TaskEventSummaryV1Result[],
metadataCache: Map<string, Record<string, unknown>>
): SpanSummary | undefined {
if (records.length === 0) {
return undefined;
}
let span: SpanSummary | undefined;
let earliestStartTime: Date | undefined;
for (const record of records) {
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
// Track the earliest start time across all records, except for ancestor overrides and span events
if (
record.kind !== "ANCESTOR_OVERRIDE" &&
record.kind !== "SPAN_EVENT" &&
(!earliestStartTime || recordStartTime < earliestStartTime)
) {
earliestStartTime = recordStartTime;
}
if (!span) {
span = {
id: spanId,
parentId: record.parent_span_id ? record.parent_span_id : undefined,
runId: record.run_id,
data: {
message: record.message,
style: {},
duration:
typeof record.duration === "number" ? record.duration : Number(record.duration),
isError: false,
isPartial: true, // Partial by default, can only be set to false
isCancelled: false,
isDebug: record.kind === "DEBUG_EVENT",
startTime: recordStartTime,
level: kindToLevel(record.kind),
events: [],
},
};
}
if (isLogEvent(record.kind)) {
span.data.isPartial = false;
span.data.isCancelled = false;
span.data.isError = record.status === "ERROR";
}
const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);
if (
parsedMetadata &&
"attemptNumber" in parsedMetadata &&
typeof parsedMetadata.attemptNumber === "number"
) {
span.data.attemptNumber = parsedMetadata.attemptNumber;
}
if (record.kind === "ANCESTOR_OVERRIDE" || record.kind === "SPAN_EVENT") {
// We need to add an event to the span
span.data.events.push({
name: record.message,
time: recordStartTime,
properties: parsedMetadata ?? {},
});
}
if (parsedMetadata && "style" in parsedMetadata && parsedMetadata.style) {
const newStyle = parsedMetadata.style as TaskEventStyle;
// Merge styles: prefer the most complete value for each field
span.data.style = {
icon: newStyle.icon ?? span.data.style.icon,
variant: newStyle.variant ?? span.data.style.variant,
accessory: newStyle.accessory ?? span.data.style.accessory,
};
}
if (record.kind === "SPAN") {
// Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
span.data.message = record.message;
if (record.status === "ERROR") {
span.data.isError = true;
span.data.isPartial = false;
span.data.isCancelled = false;
} else if (record.status === "CANCELLED") {
span.data.isCancelled = true;
span.data.isPartial = false;
span.data.isError = false;
} else if (record.status === "OK") {
span.data.isPartial = false;
}
if (record.status !== "PARTIAL") {
span.data.duration =
typeof record.duration === "number" ? record.duration : Number(record.duration);
}
}
}
// Always use the earliest start time found across all records
if (span && earliestStartTime) {
span.data.startTime = earliestStartTime;
}
return span;
}
#parseMetadata(
metadata: string,
cache: Map<string, Record<string, unknown>>
): Record<string, unknown> | undefined {
if (!metadata) {
return undefined;
}
// Check cache first
const cached = cache.get(metadata);
if (cached) {
return cached;
}
const parsed = JSON.parse(metadata);
if (typeof parsed !== "object" || parsed === null) {
return undefined;
}
const result = parsed as Record<string, unknown>;
// Cache the result - no size limit needed since cache is per-query
cache.set(metadata, result);
return result;
}
#createTraceDetailedSummaryQueryBuilder() {
return this._version === "v2"
? this._clickhouse.taskEventsV2.traceDetailedSummaryQueryBuilder()
: this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder();
}
async #fetchTraceDetailedSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
spanIds,
parentSpanIds,
limit,
skipTimeWindow,
}: {
environmentId: string;
traceId: string;
startCreatedAt?: Date;
endCreatedAt?: Date;
options?: { includeDebugLogs?: boolean };
spanIds?: string[];
parentSpanIds?: string[];
limit?: number;
skipTimeWindow?: boolean;
}): Promise<TaskEventDetailedSummaryV1Result[] | undefined> {
const queryBuilder = this.#createTraceDetailedSummaryQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
if (!skipTimeWindow) {
if (!startCreatedAt) {
throw new Error("startCreatedAt is required when skipTimeWindow is false");
}
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
queryBuilder.where("start_time >= {startCreatedAt: String}", {
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
});
if (endCreatedAt) {
queryBuilder.where("start_time <= {endCreatedAt: String}", {
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
});
}
if (this._version === "v2") {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
});
}
}
if (options?.includeDebugLogs === false) {
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
}
if (spanIds && spanIds.length > 0) {
queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds });
}
if (parentSpanIds && parentSpanIds.length > 0) {
queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds });
}
queryBuilder.orderBy("start_time ASC");
if (limit) {
queryBuilder.limit(limit);
}
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
return records;
}
#buildTraceDetailedSummaryFromRecords(
traceId: string,
records: TaskEventDetailedSummaryV1Result[],
rootSpanId?: string
): TraceDetailedSummary | undefined {
if (records.length === 0) {
return;
}
const recordsGroupedBySpanId: Record<string, TaskEventDetailedSummaryV1Result[]> = {};
for (const record of records) {
if (!recordsGroupedBySpanId[record.span_id]) {
recordsGroupedBySpanId[record.span_id] = [];
}
recordsGroupedBySpanId[record.span_id].push(record);
}
const spanSummaries = new Map<string, SpanDetailedSummary>();
let resolvedRootSpanId: string | undefined = rootSpanId;
const metadataCache = new Map<string, Record<string, unknown>>();
for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
const spanSummary = this.#mergeRecordsIntoSpanDetailedSummary(
spanId,
spanRecords,
metadataCache
);
if (!spanSummary) {
continue;
}
spanSummaries.set(spanId, spanSummary);
if (!resolvedRootSpanId && !spanSummary.parentId) {
resolvedRootSpanId = spanId;
}
}
if (!resolvedRootSpanId) {
return;
}
const spans = Array.from(spanSummaries.values());
const overridesBySpanId: Record<string, SpanOverride> = {};
const spanDetailedSummaryMap = new Map<string, SpanDetailedSummary>();
const finalSpans = spans.map((span) => {
const finalSpan = this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
spanDetailedSummaryMap.set(span.id, finalSpan);
return finalSpan;
});
for (const finalSpan of finalSpans) {
if (finalSpan.parentId) {
const parent = spanDetailedSummaryMap.get(finalSpan.parentId);
if (parent) {
parent.children.push(finalSpan);
}
}
}
const rootSpan = spanDetailedSummaryMap.get(resolvedRootSpanId);
if (!rootSpan) {
return;
}
return {
traceId,
rootSpan,
};
}
async getTraceDetailedSummary(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): Promise<TraceDetailedSummary | undefined> {
const limit = this._config.maximumTraceDetailedSummaryViewCount;
const records = await this.#fetchTraceDetailedSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
limit,
});
if (!records) {
return;
}
const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records);
if (!summary) {
return;
}
return {
...summary,
isTruncated: limit !== undefined && records.length >= limit,
};
}
async getTraceDetailedSubtreeSummary(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
anchorSpanId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): Promise<TraceDetailedSummary | undefined> {
const limit = this._config.maximumTraceDetailedSummaryViewCount;
// Try one capped full-trace query first so the common case stays at a single
// round-trip; large traces pay an extra fetch before the subtree walk below.
const fullRecords = await this.#fetchTraceDetailedSummaryRecords({
environmentId,
traceId,
startCreatedAt,
endCreatedAt,
options,
limit,
});
if (fullRecords && this.#canReRootDetailedRecordsAtAnchor(fullRecords, anchorSpanId)) {
const summary = this.#buildTraceDetailedSummaryFromRecords(
traceId,
fullRecords,
anchorSpanId
);
if (summary) {
return {
...summary,
isTruncated: limit !== undefined && fullRecords.length >= limit,
};
}
}
const { records, isTruncated, missingAnchor } = await this.#fetchTraceDetailedSubtreeRecords({
environmentId,
traceId,
anchorSpanId,
startCreatedAt,
endCreatedAt,
options,
limit,
});
if (missingAnchor) {
return;
}
const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records, anchorSpanId);
if (!summary) {
return;
}
return {
...summary,
isTruncated,
};
}
// Only checks the direct parent — not the full ancestor chain. Safe in practice
// because ancestors have earlier start_time and usually land inside the cap
// when the anchor does; otherwise we fall back to the subtree walk.
#canReRootDetailedRecordsAtAnchor(
records: Array<{ span_id: string; parent_span_id: string }>,
anchorSpanId: string
): boolean {
const anchorRecord = records.find((record) => record.span_id === anchorSpanId);
if (!anchorRecord) {
return false;
}
const parentSpanId = anchorRecord.parent_span_id;
if (!parentSpanId) {
return true;
}
return records.some((record) => record.span_id === parentSpanId);
}
async *streamTraceEvents(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
startCreatedAt: Date,
endCreatedAt?: Date,
options?: { includeDebugLogs?: boolean }
): AsyncIterable<StreamedTraceEvent> {
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
const queryBuilder =
this._version === "v2"
? this._clickhouse.taskEventsV2.traceEventsForExportQueryBuilder()
: this._clickhouse.taskEvents.traceEventsForExportQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
queryBuilder.where("start_time >= {startCreatedAt: String}", {
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
});
if (endCreatedAt) {
queryBuilder.where("start_time <= {endCreatedAt: String}", {
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
});
}
if (this._version === "v2") {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
});
}
// Admin-only debug events stay hidden unless explicitly requested.
if (options?.includeDebugLogs !== true) {
queryBuilder.where("kind != {debugKind: String}", { debugKind: "DEBUG_EVENT" });
}
// Each span is written twice: a PARTIAL start-marker (empty attributes) and
// the completed row. Keep only the completed row so the export has one line
// per span (the tree path merges these; streaming can't, so we filter).
queryBuilder.where("status != {partialStatus: String}", { partialStatus: "PARTIAL" });
// Internal trigger.dev span events (start timeline) are uninformative noise
// in the export; the tree path filters them too. Real span events such as
// exceptions are kept.
queryBuilder.where(
"(kind != {spanEventKind: String} OR NOT startsWith(message, {internalPrefix: String}))",
{ spanEventKind: "SPAN_EVENT", internalPrefix: "trigger.dev/" }
);
// ANCESTOR_OVERRIDE rows duplicate a descendant's error onto an ancestor span
// to colour the tree; they carry no event of their own. The tree path drops
// them, so the export does too (otherwise the same error shows up twice).
queryBuilder.where("kind != {ancestorKind: String}", { ancestorKind: "ANCESTOR_OVERRIDE" });
queryBuilder.orderBy("start_time ASC");
// Deliberately no LIMIT: streaming never materialises the result set, so the
// detailed-summary memory cap doesn't apply to the export.
for await (const row of queryBuilder.executeStream()) {
yield {
spanId: row.span_id,
parentSpanId: row.parent_span_id,
startTime: convertClickhouseDateTime64ToJsDate(row.start_time),
durationNs: typeof row.duration === "number" ? row.duration : Number(row.duration),
level: clickhouseKindToLevel(row.kind),
message: row.message,
isError: row.status === "ERROR",
propertiesText: row.attributes_text ?? "",
};
}
}
#mergeRecordsIntoSpanDetailedSummary(
spanId: string,
records: TaskEventDetailedSummaryV1Result[],
metadataCache: Map<string, Record<string, unknown>>
): SpanDetailedSummary | undefined {
if (records.length === 0) {
return undefined;
}
let span: SpanDetailedSummary | undefined;
let earliestStartTime: Date | undefined;
for (const record of records) {
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
// Track the earliest start time across all records
if (
record.kind !== "ANCESTOR_OVERRIDE" &&
record.kind !== "SPAN_EVENT" &&
(!earliestStartTime || recordStartTime < earliestStartTime)
) {
earliestStartTime = recordStartTime;
}
if (!span) {
span = {
id: spanId,
parentId: record.parent_span_id ? record.parent_span_id : undefined,
runId: record.run_id,
data: {
message: record.message,
taskSlug: undefined,
duration:
typeof record.duration === "number" ? record.duration : Number(record.duration),
isError: false,
isPartial: true, // Partial by default, can only be set to false
isCancelled: false,
startTime: recordStartTime,
level: kindToLevel(record.kind),
events: [],
},
children: [],
};
}
if (isLogEvent(record.kind)) {
span.data.isPartial = false;
span.data.isCancelled = false;
span.data.isError = record.status === "ERROR";
}
const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);
if (
parsedMetadata &&
"attemptNumber" in parsedMetadata &&
typeof parsedMetadata.attemptNumber === "number"
) {
span.data.attemptNumber = parsedMetadata.attemptNumber;
}
if (record.kind === "ANCESTOR_OVERRIDE" || record.kind === "SPAN_EVENT") {
// We need to add an event to the span
span.data.events.push({
name: record.message,
time: recordStartTime,
properties: parsedMetadata ?? {},
});
}
if (record.kind === "SPAN") {
// Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
span.data.message = record.message;
if (record.status === "ERROR") {
span.data.isError = true;
span.data.isPartial = false;
span.data.isCancelled = false;
} else if (record.status === "CANCELLED") {
span.data.isCancelled = true;
span.data.isPartial = false;
span.data.isError = false;
} else if (record.status === "OK") {
span.data.isPartial = false;
}
if (record.status !== "PARTIAL") {
span.data.duration =
typeof record.duration === "number" ? record.duration : Number(record.duration);
}
}
}
// Always use the earliest start time found across all records
if (span && earliestStartTime) {
span.data.startTime = earliestStartTime;
}
return span;
}
async getRunEvents(
storeTable: TaskEventStoreTable,
environmentId: string,
traceId: string,
runId: string,
startCreatedAt: Date,
endCreatedAt?: Date
): Promise<RunPreparedEvent[]> {
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
const queryBuilder =
this._version === "v2"
? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
: this._clickhouse.taskEvents.traceSummaryQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
queryBuilder.where("run_id = {runId: String}", { runId });
queryBuilder.where("start_time >= {startCreatedAt: String}", {
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
});
if (endCreatedAt) {
queryBuilder.where("start_time <= {endCreatedAt: String}", {
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
});
}
// For v2, add inserted_at filtering for partition pruning
if (this._version === "v2") {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
});
}
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
queryBuilder.orderBy("start_time ASC");
if (this._config.maximumTraceSummaryViewCount) {
queryBuilder.limit(this._config.maximumTraceSummaryViewCount);
}
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (!records) {
return [];
}
// O(n) grouping instead of O(n²) array spreading
const recordsGroupedBySpanId: Record<string, TaskEventSummaryV1Result[]> = {};
for (const record of records) {
if (!recordsGroupedBySpanId[record.span_id]) {
recordsGroupedBySpanId[record.span_id] = [];
}
recordsGroupedBySpanId[record.span_id].push(record);
}
const spanSummaries = new Map<string, SpanSummary>();
let rootSpanId: string | undefined;
// Create temporary metadata cache for this query
const metadataCache = new Map<string, Record<string, unknown>>();
for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
const spanSummary = this.#mergeRecordsIntoSpanSummary(spanId, spanRecords, metadataCache);
if (!spanSummary) {
continue;
}
spanSummaries.set(spanId, spanSummary);
// Find root span for optimized override algorithm
if (!rootSpanId && !spanSummary.parentId) {
rootSpanId = spanId;
}
}
const spans = Array.from(spanSummaries.values());
const overridesBySpanId: Record<string, SpanOverride> = {};
const finalSpans = spans.map((span) => {
return this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
});
const runPreparedEvents = finalSpans.map((span) => this.#spanSummaryToRunPreparedEvent(span));
return runPreparedEvents;
}
#spanSummaryToRunPreparedEvent(span: SpanSummary): RunPreparedEvent {
return {
spanId: span.id,
parentId: span.parentId ?? null,
runId: span.runId,
message: span.data.message,
style: span.data.style,
events: span.data.events,
startTime: convertDateToNanoseconds(span.data.startTime),
duration: span.data.duration,
isError: span.data.isError,
isPartial: span.data.isPartial,
isCancelled: span.data.isCancelled,
kind: "UNSPECIFIED",
attemptNumber: span.data.attemptNumber ?? null,
level: span.data.level,
};
}
}
// Precompile regex for performance (used ~30k times per trace)
const CLICKHOUSE_DATETIME_REGEX =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:Z|([+-])(\d{2}):?(\d{2}))?$/;
export const convertDateToClickhouseDateTime = (date: Date): string => {
// 2024-11-06T20:37:00.123Z -> 2024-11-06 21:37:00.123
return date.toISOString().replace("T", " ").replace("Z", "");
};
/**
* Convert a ClickHouse DateTime64 to nanoseconds since epoch (UTC).
* Accepts:
* - "2025-09-23 12:32:46.130262875"
* - "2025-09-23T12:32:46.13"
* - "2025-09-23 12:32:46Z"
* - "2025-09-23 12:32:46.130262875+02:00"
*/
export function convertClickhouseDateTime64ToNanosecondsEpoch(date: string): bigint {
const s = date.trim();
const m = CLICKHOUSE_DATETIME_REGEX.exec(s);
if (!m) {
throw new Error(`Invalid ClickHouse DateTime64 string: "${date}"`);
}
const year = Number(m[1]);
const month = Number(m[2]); // 1-12
const day = Number(m[3]); // 1-31
const hour = Number(m[4]);
const minute = Number(m[5]);
const second = Number(m[6]);
const fraction = m[7] ?? ""; // up to 9 digits
const sign = m[8] as "+" | "-" | undefined;
const offH = m[9] ? Number(m[9]) : 0;
const offM = m[10] ? Number(m[10]) : 0;
// Convert fractional seconds to exactly 9 digits (nanoseconds within the second).
const nsWithinSecond = Number(fraction.padEnd(9, "0")); // 0..999_999_999
// Split into millisecond part (for Date) and leftover nanoseconds.
const msPart = Math.trunc(nsWithinSecond / 1_000_000); // 0..999
const leftoverNs = nsWithinSecond - msPart * 1_000_000; // 0..999_999
// Build milliseconds since epoch in UTC using Date.UTC (avoids local TZ/DST issues).
let msEpoch = Date.UTC(year, month - 1, day, hour, minute, second, msPart);
// If an explicit offset was provided, adjust to true UTC.
if (sign) {
const offsetMinutesSigned = (sign === "+" ? 1 : -1) * (offH * 60 + offM);
msEpoch -= offsetMinutesSigned * 60_000;
}
// Combine ms to ns with leftover.
return BigInt(msEpoch) * 1_000_000n + BigInt(leftoverNs);
}
/**
* Convert a ClickHouse DateTime64 to a JS Date.
* Accepts:
* - "2025-09-23 12:32:46.130262875"
* - "2025-09-23T12:32:46.13"
* - "2025-09-23 12:32:46Z"
* - "2025-09-23 12:32:46.130262875+02:00"
*
* Optimized with fast path for common format (avoids regex for 99% of cases).
*/
// Map a ClickHouse task-event `kind` to a human display level for the streaming
// export (e.g. LOG_INFO -> INFO, SPAN -> TRACE, SPAN_EVENT -> EVENT).
function clickhouseKindToLevel(kind: string): string {
if (kind.startsWith("LOG_")) return kind.slice(4);
if (kind === "SPAN") return "TRACE";
if (kind === "SPAN_EVENT") return "EVENT";
if (kind === "DEBUG_EVENT") return "DEBUG";
return kind;
}
export function convertClickhouseDateTime64ToJsDate(date: string): Date {
// Fast path for common format: "2025-09-23 12:32:46.130262875" or "2025-09-23 12:32:46"
// This avoids the expensive regex for the common case
if (date.length >= 19 && date[4] === "-" && date[7] === "-" && date[10] === " ") {
const year = Number(date.substring(0, 4));
const month = Number(date.substring(5, 7));
const day = Number(date.substring(8, 10));
const hour = Number(date.substring(11, 13));
const minute = Number(date.substring(14, 16));
const second = Number(date.substring(17, 19));
// Parse fractional seconds if present
let ms = 0;
if (date.length > 20 && date[19] === ".") {
// Take first 3 digits after decimal (milliseconds), pad if shorter
const fracStr = date.substring(20, Math.min(23, date.length));
ms = Number(fracStr.padEnd(3, "0"));
}
return new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms));
}
// Fallback to regex for other formats (T separator, timezone offsets, etc.)
const s = date.trim();
const m = CLICKHOUSE_DATETIME_REGEX.exec(s);
if (!m) {
throw new Error(`Invalid ClickHouse DateTime64 string: "${date}"`);
}
const year = Number(m[1]);
const month = Number(m[2]); // 1-12
const day = Number(m[3]); // 1-31
const hour = Number(m[4]);
const minute = Number(m[5]);
const second = Number(m[6]);
const fraction = m[7] ?? ""; // up to 9 digits
// Convert fractional seconds to exactly 9 digits (nanoseconds within the second).
const nsWithinSecond = Number(fraction.padEnd(9, "0")); // 0..999_999_999
// Split into millisecond part (for Date)
const msPart = Math.trunc(nsWithinSecond / 1_000_000); // 0..999
return new Date(Date.UTC(year, month - 1, day, hour, minute, second, msPart));
}
function kindToLevel(kind: string): TaskEventLevel {
switch (kind) {
case "DEBUG_EVENT":
case "LOG_DEBUG": {
return "DEBUG";
}
case "LOG_LOG": {
return "LOG";
}
case "LOG_INFO": {
return "INFO";
}
case "LOG_WARN": {
return "WARN";
}
case "LOG_ERROR": {
return "ERROR";
}
case "SPAN":
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT": {
return "TRACE";
}
default: {
return "TRACE";
}
}
}
function isLogEvent(kind: string): boolean {
return kind.startsWith("LOG_") || kind === "DEBUG_EVENT";
}
function calculateEndTimeFromStartTime(startTime: Date, duration: number): Date {
return new Date(startTime.getTime() + duration / 1_000_000);
}
// This will take a string like "1759427319944999936" and return "1759427319.944999936"
function formatClickhouseDate64NanosecondsEpochString(date: string): string {
if (date.length !== 19) {
return date;
}
return date.substring(0, 10) + "." + date.substring(10);
}
function convertClickhouseDate64NanosecondsEpochStringToBigInt(date: string): bigint {
const parts = date.split(".");
return BigInt(parts.join(""));
}
function formatClickhouseUnsignedIntegerString(value: number | bigint): string {
if (value < 0) {
return "0";
}
if (typeof value === "bigint") {
return value.toString();
}
return Math.floor(value).toString();
}