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; private readonly _llmMetricsFlushScheduler: DynamicFlushScheduler; private readonly _otlpMetricsFlushScheduler: DynamicFlushScheduler; 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( flushId: string, rows: T[], doInsert: () => Promise, 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 { 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 { if (!attributes && !resourceAttributes) { return {}; } return { ...this.createAttributesToInputAttributes(attributes), ...this.createAttributesToInputAttributes(resourceAttributes, "$resource"), }; } private createAttributesToInputAttributes( attributes: Attributes | undefined, key?: string ): Record { 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 { 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( message: string, options: TraceEventOptions & { incomplete?: boolean; isError?: boolean }, callback: ( e: EventBuilder, traceContext: Record, traceparent?: { traceId: string; spanId: string } ) => Promise ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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({ anchorSpanId, maxRows, fetchAncestor, fetchDescendant, }: { anchorSpanId: string; maxRows?: number; fetchAncestor: (batch: { spanIds: string[]; limit?: number }) => Promise; fetchDescendant: (batch: { spanIds?: string[]; parentSpanIds?: string[]; limit?: number; }) => Promise; }): Promise<{ records: T[]; isTruncated: boolean; missingAnchor: boolean; }> { const allRecords: T[] = []; const collectedSpanIds = new Set(); 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 { 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 = {}; for (const record of records) { if (!recordsGroupedBySpanId[record.span_id]) { recordsGroupedBySpanId[record.span_id] = []; } recordsGroupedBySpanId[record.span_id].push(record); } const spanSummaries = new Map(); let rootSpanId: string | undefined = options?.rootSpanId; const metadataCache = new Map>(); 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 = {}; 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 { 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>(); const span = this.#mergeRecordsIntoSpanDetail(spanId, records, metadataCache); return span; } #mergeRecordsIntoSpanDetail( spanId: string, records: TaskEventDetailsV1Result[], metadataCache: Map> ): 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 | undefined; } } } // Always use the earliest start time found across all records if (span && earliestStartTime) { span.startTime = earliestStartTime; } return span; } #parseAttributes(attributes_text: string): Record { if (!attributes_text) { return {}; } return JSON.parse(attributes_text) as Record; } #applyAncestorOverrides( span: TSpanSummary, spansById: Map, overridesBySpanId: Record ): 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( span: TSpanSummary, overrideSpan: TSpanSummary, overridesBySpanId: Record ): 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> ): 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> ): Record | 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; // 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 { 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 = {}; for (const record of records) { if (!recordsGroupedBySpanId[record.span_id]) { recordsGroupedBySpanId[record.span_id] = []; } recordsGroupedBySpanId[record.span_id].push(record); } const spanSummaries = new Map(); let resolvedRootSpanId: string | undefined = rootSpanId; const metadataCache = new Map>(); 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 = {}; const spanDetailedSummaryMap = new Map(); 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 { 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 { 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 { 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> ): 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 { 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 = {}; for (const record of records) { if (!recordsGroupedBySpanId[record.span_id]) { recordsGroupedBySpanId[record.span_id] = []; } recordsGroupedBySpanId[record.span_id].push(record); } const spanSummaries = new Map(); let rootSpanId: string | undefined; // Create temporary metadata cache for this query const metadataCache = new Map>(); 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 = {}; 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(); }