2880 lines
88 KiB
TypeScript
2880 lines
88 KiB
TypeScript
import type {
|
|
ClickHouse,
|
|
ClickHouseSettings,
|
|
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 {
|
|
insertWithBadRowSkip,
|
|
type JsonParseRecoveryOutcome,
|
|
landedNothing,
|
|
} 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 where every row was un-ingestable, so nothing landed. Only
|
|
* incremented when ClickHouse's summary says so exactly (`written_rows === 0`);
|
|
* expected to stay at zero, since a whole batch of un-ingestable events means
|
|
* something upstream is broken rather than one bad payload.
|
|
*/
|
|
private _permanentlyDroppedBatches = 0;
|
|
private readonly _droppedBatchesCounter: Counter;
|
|
|
|
/**
|
|
* Counts batches that took the bad-row-skip recovery path: a
|
|
* `Cannot parse JSON object` failure the sanitizer could not repair, where one
|
|
* `allow_errors` insert landed the good rows and skipped the un-ingestable
|
|
* ones. Every such batch lost at least one row, so this is the alertable
|
|
* signal for these tables.
|
|
*/
|
|
private _rowIsolationRecoveries = 0;
|
|
private readonly _rowIsolatedBatchesCounter: Counter;
|
|
|
|
/**
|
|
* Counts rows skipped as un-ingestable. A floor, not an exact count: these
|
|
* tables carry row-multiplying materialized views, so ClickHouse's insert
|
|
* summary can't separate skipped base rows from MV rows (see `droppedRowCount`).
|
|
*/
|
|
private _permanentlyDroppedRows = 0;
|
|
private readonly _rowsDroppedCounter: 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._rowIsolatedBatchesCounter = meter.createCounter("ingest.flush.batches_row_isolated", {
|
|
description:
|
|
"Batches recovered by skipping un-ingestable rows (landed the rest) after a ClickHouse JSON parse error; each lost at least one row",
|
|
unit: "batches",
|
|
});
|
|
this._rowsDroppedCounter = meter.createCounter("ingest.flush.rows_dropped", {
|
|
description:
|
|
"Rows skipped as un-ingestable, as a lower bound: these tables' materialized views make the exact count underivable from ClickHouse's insert summary",
|
|
unit: "rows",
|
|
});
|
|
|
|
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 — batches where nothing landed even after stripping JSON. */
|
|
get permanentlyDroppedBatches() {
|
|
return this._permanentlyDroppedBatches;
|
|
}
|
|
|
|
/** Exposed for tests and metrics — batches that took the bad-row-skip recovery path. */
|
|
get rowIsolationRecoveries() {
|
|
return this._rowIsolationRecoveries;
|
|
}
|
|
|
|
/** Exposed for tests and metrics — rows skipped as un-ingestable (a lower bound). */
|
|
get permanentlyDroppedRows() {
|
|
return this._permanentlyDroppedRows;
|
|
}
|
|
|
|
/**
|
|
* 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 contextLabel = `task_events_${this._version}`;
|
|
const rawInsert = async (
|
|
rows: (TaskEventV1Input | TaskEventV2Input)[],
|
|
extraSettings?: ClickHouseSettings
|
|
) => {
|
|
const [insertError, insertResult] = await insertFn(rows, {
|
|
params: {
|
|
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
|
|
},
|
|
});
|
|
if (insertError) throw insertError;
|
|
return insertResult;
|
|
};
|
|
|
|
const outcome = await insertWithBadRowSkip({
|
|
rows: events,
|
|
contextLabel,
|
|
logger,
|
|
logContext: { flushId, version: this._version },
|
|
insert: (rows) => rawInsert(rows),
|
|
insertAllowingBadRows: (rows) =>
|
|
rawInsert(rows, {
|
|
async_insert: 0,
|
|
input_format_parallel_parsing: 0,
|
|
input_format_allow_errors_num: String(rows.length),
|
|
input_format_allow_errors_ratio: 1,
|
|
}),
|
|
});
|
|
this.#recordRecoveryOutcome(outcome, contextLabel, events.length);
|
|
|
|
if (landedNothing(outcome, events.length)) {
|
|
return;
|
|
}
|
|
|
|
logger.debug("ClickhouseEventRepository.flushBatch Inserted batch into clickhouse", {
|
|
events: events.length,
|
|
outcome: outcome.kind,
|
|
version: this._version,
|
|
});
|
|
|
|
this.#publishToRedis(events);
|
|
});
|
|
}
|
|
|
|
async #flushLlmMetricsBatch(flushId: string, rows: LlmMetricsV1Input[]) {
|
|
const rawInsert = async (batch: LlmMetricsV1Input[], extraSettings?: ClickHouseSettings) => {
|
|
const [insertError, insertResult] = await this._clickhouse.llmMetrics.insert(batch, {
|
|
params: {
|
|
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
|
|
},
|
|
});
|
|
if (insertError) throw insertError;
|
|
return insertResult;
|
|
};
|
|
|
|
const outcome = await insertWithBadRowSkip({
|
|
rows,
|
|
contextLabel: "llm_metrics_v1",
|
|
logger,
|
|
logContext: { flushId },
|
|
insert: (batch) => rawInsert(batch),
|
|
insertAllowingBadRows: (batch) =>
|
|
rawInsert(batch, {
|
|
async_insert: 0,
|
|
input_format_parallel_parsing: 0,
|
|
input_format_allow_errors_num: String(batch.length),
|
|
input_format_allow_errors_ratio: 1,
|
|
}),
|
|
});
|
|
this.#recordRecoveryOutcome(outcome, "llm_metrics_v1", rows.length);
|
|
|
|
if (landedNothing(outcome, rows.length)) {
|
|
return;
|
|
}
|
|
|
|
logger.debug("ClickhouseEventRepository.flushLlmMetricsBatch Inserted LLM metrics batch", {
|
|
rows: rows.length,
|
|
outcome: outcome.kind,
|
|
});
|
|
}
|
|
|
|
#recordRecoveryOutcome(
|
|
outcome: JsonParseRecoveryOutcome,
|
|
contextLabel: string,
|
|
batchSize: number
|
|
) {
|
|
if (outcome.kind !== "recovered") {
|
|
return;
|
|
}
|
|
|
|
this._rowIsolationRecoveries += 1;
|
|
this._rowIsolatedBatchesCounter.add(1, { table: contextLabel });
|
|
|
|
if (outcome.rowsDropped > 0) {
|
|
this._permanentlyDroppedRows += outcome.rowsDropped;
|
|
this._rowsDroppedCounter.add(outcome.rowsDropped, { table: contextLabel });
|
|
if (outcome.rowsDroppedExact && outcome.rowsDropped === batchSize) {
|
|
this._permanentlyDroppedBatches += 1;
|
|
this._droppedBatchesCounter.add(1, { table: contextLabel });
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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();
|
|
}
|