chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import type { Attributes } from "@opentelemetry/api";
|
||||
import { RandomIdGenerator } from "@opentelemetry/sdk-trace-base";
|
||||
import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type {
|
||||
ExceptionEventProperties,
|
||||
SpanEvents,
|
||||
TaskRunError,
|
||||
} from "@trigger.dev/core/v3/schemas";
|
||||
import { unflattenAttributes } from "@trigger.dev/core/v3/utils/flattenAttributes";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function extractContextFromCarrier(carrier: Record<string, unknown>) {
|
||||
const traceparent = carrier["traceparent"];
|
||||
const tracestate = carrier["tracestate"];
|
||||
|
||||
if (typeof traceparent !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...carrier,
|
||||
traceparent: parseTraceparent(traceparent),
|
||||
tracestate,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNowInNanoseconds(): bigint {
|
||||
return BigInt(new Date().getTime() * 1_000_000);
|
||||
}
|
||||
|
||||
export function getDateFromNanoseconds(nanoseconds: bigint): Date {
|
||||
return new Date(Number(nanoseconds) / 1_000_000);
|
||||
}
|
||||
|
||||
export function calculateDurationFromStart(
|
||||
startTime: bigint,
|
||||
endTime: Date = new Date(),
|
||||
minimumDuration?: number
|
||||
) {
|
||||
const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime;
|
||||
|
||||
const duration = Number(BigInt($endtime.getTime() * 1_000_000) - startTime);
|
||||
|
||||
if (minimumDuration && duration < minimumDuration) {
|
||||
return minimumDuration;
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
export function calculateDurationFromStartJsDate(startTime: Date, endTime: Date = new Date()) {
|
||||
const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime;
|
||||
|
||||
return ($endtime.getTime() - startTime.getTime()) * 1_000_000;
|
||||
}
|
||||
|
||||
export function convertDateToNanoseconds(date: Date): bigint {
|
||||
return BigInt(date.getTime()) * BigInt(1_000_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deterministically random 8-byte span ID formatted/encoded as a 16 lowercase hex
|
||||
* characters corresponding to 64 bits, based on the trace ID and seed.
|
||||
*/
|
||||
export function generateDeterministicSpanId(traceId: string, seed: string) {
|
||||
const hash = createHash("sha1");
|
||||
hash.update(traceId);
|
||||
hash.update(seed);
|
||||
const buffer = hash.digest();
|
||||
let hexString = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const val = buffer.readUInt8(i);
|
||||
const str = val.toString(16).padStart(2, "0");
|
||||
hexString += str;
|
||||
}
|
||||
return hexString;
|
||||
}
|
||||
|
||||
const randomIdGenerator = new RandomIdGenerator();
|
||||
|
||||
export function generateTraceId() {
|
||||
return randomIdGenerator.generateTraceId();
|
||||
}
|
||||
|
||||
export function generateSpanId() {
|
||||
return randomIdGenerator.generateSpanId();
|
||||
}
|
||||
|
||||
export function stripAttributePrefix(attributes: Attributes, prefix: string) {
|
||||
const result: Attributes = {};
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
result[key.slice(prefix.length + 1)] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseEventsField(events: unknown): SpanEvents {
|
||||
if (!events) return [];
|
||||
if (!Array.isArray(events)) return [];
|
||||
|
||||
const unsafe = events
|
||||
? (events as any[]).map((e) => ({
|
||||
...e,
|
||||
properties: unflattenAttributes(e.properties as Attributes),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return unsafe as SpanEvents;
|
||||
}
|
||||
|
||||
export function createExceptionPropertiesFromError(error: TaskRunError): ExceptionEventProperties {
|
||||
switch (error.type) {
|
||||
case "BUILT_IN_ERROR": {
|
||||
return {
|
||||
type: error.name,
|
||||
message: error.message,
|
||||
stacktrace: error.stackTrace,
|
||||
};
|
||||
}
|
||||
case "CUSTOM_ERROR": {
|
||||
return {
|
||||
type: "Error",
|
||||
message: error.raw,
|
||||
};
|
||||
}
|
||||
case "INTERNAL_ERROR": {
|
||||
return {
|
||||
type: "Internal error",
|
||||
message: [error.code, error.message].filter(Boolean).join(": "),
|
||||
stacktrace: error.stackTrace,
|
||||
};
|
||||
}
|
||||
case "STRING_ERROR": {
|
||||
return {
|
||||
type: "Error",
|
||||
message: error.raw,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removes internal/private attribute keys from span properties.
|
||||
// Filters: "$" prefixed keys (private metadata) and "ctx." prefixed keys (Trigger.dev run context)
|
||||
export function removePrivateProperties(
|
||||
attributes: Attributes | undefined | null
|
||||
): Attributes | undefined {
|
||||
if (!attributes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Attributes = {};
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key.startsWith("$") || key.startsWith("ctx.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isEmptyObject(obj: object) {
|
||||
for (var prop in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { env } from "~/env.server";
|
||||
|
||||
// Emergency circuit breaker for trace views: when TRACE_VIEW_EMERGENCY_SPAN_CAP
|
||||
// is set, clamp a trace summary span limit to it. Unset = no clamping.
|
||||
export function clampToEmergencySpanCap(limit: number): number {
|
||||
const cap = env.TRACE_VIEW_EMERGENCY_SPAN_CAP;
|
||||
return cap === undefined ? limit : Math.min(limit, cap);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
import type { Attributes, Tracer } from "@opentelemetry/api";
|
||||
import type {
|
||||
ExceptionEventProperties,
|
||||
SpanEvents,
|
||||
TaskEventEnvironment,
|
||||
TaskEventStyle,
|
||||
TaskRunError,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import type {
|
||||
Prisma,
|
||||
TaskEvent,
|
||||
TaskEventKind,
|
||||
TaskEventLevel,
|
||||
TaskEventStatus,
|
||||
TaskRun,
|
||||
} from "@trigger.dev/database";
|
||||
import type { MetricsV1Input } from "@internal/clickhouse";
|
||||
import type { DetailedTraceEvent, TaskEventStoreTable } from "../taskEventStore.server";
|
||||
export type { ExceptionEventProperties };
|
||||
|
||||
// ============================================================================
|
||||
// Event Creation Types
|
||||
// ============================================================================
|
||||
|
||||
export type LlmMetricsData = {
|
||||
genAiSystem: string;
|
||||
requestModel: string;
|
||||
responseModel: string;
|
||||
baseResponseModel: string;
|
||||
matchedModelId: string;
|
||||
operationId: string;
|
||||
finishReason: string;
|
||||
costSource: string;
|
||||
pricingTierId: string;
|
||||
pricingTierName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
usageDetails: Record<string, number>;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
totalCost: number;
|
||||
costDetails: Record<string, number>;
|
||||
providerCost: number;
|
||||
msToFirstChunk: number;
|
||||
tokensPerSecond: number;
|
||||
metadata: Record<string, string>;
|
||||
promptSlug: string;
|
||||
promptVersion: number;
|
||||
};
|
||||
|
||||
export type CreateEventInput = Omit<
|
||||
Prisma.TaskEventCreateInput,
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "properties"
|
||||
| "metadata"
|
||||
| "style"
|
||||
| "output"
|
||||
| "payload"
|
||||
| "serviceName"
|
||||
| "serviceNamespace"
|
||||
| "tracestate"
|
||||
| "projectRef"
|
||||
| "runIsTest"
|
||||
| "workerId"
|
||||
| "queueId"
|
||||
| "queueName"
|
||||
| "batchId"
|
||||
| "taskPath"
|
||||
| "taskExportName"
|
||||
| "workerVersion"
|
||||
| "idempotencyKey"
|
||||
| "attemptId"
|
||||
| "usageDurationMs"
|
||||
| "usageCostInCents"
|
||||
| "machinePreset"
|
||||
| "machinePresetCpu"
|
||||
| "machinePresetMemory"
|
||||
| "machinePresetCentsPerMs"
|
||||
| "links"
|
||||
> & {
|
||||
properties: Attributes;
|
||||
resourceProperties?: Attributes;
|
||||
metadata: Attributes | undefined;
|
||||
style: Attributes | undefined;
|
||||
machineId?: string;
|
||||
runTags?: string[];
|
||||
/** Side-channel data for LLM cost tracking, populated by enrichCreatableEvents */
|
||||
_llmMetrics?: LlmMetricsData;
|
||||
};
|
||||
|
||||
export type CreatableEventKind = TaskEventKind;
|
||||
export type CreatableEventStatus = TaskEventStatus;
|
||||
|
||||
// ============================================================================
|
||||
// Task Run Types
|
||||
// ============================================================================
|
||||
|
||||
export type CompleteableTaskRun = Pick<
|
||||
TaskRun,
|
||||
| "friendlyId"
|
||||
| "traceId"
|
||||
| "spanId"
|
||||
| "parentSpanId"
|
||||
| "createdAt"
|
||||
| "completedAt"
|
||||
| "taskIdentifier"
|
||||
| "projectId"
|
||||
| "runtimeEnvironmentId"
|
||||
| "organizationId"
|
||||
| "isTest"
|
||||
>;
|
||||
|
||||
// ============================================================================
|
||||
// Trace and Event Types
|
||||
// ============================================================================
|
||||
|
||||
export type TraceAttributes = Partial<
|
||||
Pick<
|
||||
CreateEventInput,
|
||||
"isError" | "isCancelled" | "isDebug" | "runId" | "metadata" | "properties" | "style"
|
||||
>
|
||||
>;
|
||||
|
||||
export type SetAttribute<T extends TraceAttributes> = (key: keyof T, value: T[keyof T]) => void;
|
||||
|
||||
export type TraceEventOptions = {
|
||||
kind?: CreatableEventKind;
|
||||
context?: Record<string, unknown>;
|
||||
spanParentAsLink?: boolean;
|
||||
spanIdSeed?: string;
|
||||
attributes: TraceAttributes;
|
||||
environment: TaskEventEnvironment;
|
||||
taskSlug: string;
|
||||
startTime?: bigint;
|
||||
endTime?: Date;
|
||||
immediate?: boolean;
|
||||
};
|
||||
|
||||
export type EventBuilder = {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
setAttribute: SetAttribute<TraceAttributes>;
|
||||
stop: () => void;
|
||||
failWithError: (error: TaskRunError) => void;
|
||||
};
|
||||
|
||||
export type UpdateEventOptions = {
|
||||
attributes: TraceAttributes;
|
||||
endTime?: Date;
|
||||
immediate?: boolean;
|
||||
events?: SpanEvents;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export type EventRepoConfig = {
|
||||
batchSize: number;
|
||||
batchInterval: number;
|
||||
retentionInDays: number;
|
||||
partitioningEnabled: boolean;
|
||||
tracer?: Tracer;
|
||||
minConcurrency?: number;
|
||||
maxConcurrency?: number;
|
||||
maxBatchSize?: number;
|
||||
memoryPressureThreshold?: number;
|
||||
loadSheddingThreshold?: number;
|
||||
loadSheddingEnabled?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Query Types
|
||||
// ============================================================================
|
||||
|
||||
export type QueryOptions = Prisma.TaskEventWhereInput;
|
||||
|
||||
export type TaskEventRecord = TaskEvent;
|
||||
|
||||
export type QueriedEvent = Prisma.TaskEventGetPayload<{
|
||||
select: {
|
||||
spanId: true;
|
||||
parentId: true;
|
||||
runId: true;
|
||||
message: true;
|
||||
style: true;
|
||||
startTime: true;
|
||||
duration: true;
|
||||
isError: true;
|
||||
isPartial: true;
|
||||
isCancelled: true;
|
||||
level: true;
|
||||
events: true;
|
||||
kind: true;
|
||||
attemptNumber: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PreparedEvent = Omit<QueriedEvent, "events" | "style" | "duration"> & {
|
||||
duration: number;
|
||||
events: SpanEvents;
|
||||
style: TaskEventStyle;
|
||||
};
|
||||
|
||||
export type PreparedDetailedEvent = Omit<DetailedTraceEvent, "events" | "style" | "duration"> & {
|
||||
duration: number;
|
||||
events: SpanEvents;
|
||||
style: TaskEventStyle;
|
||||
};
|
||||
|
||||
export type RunPreparedEvent = PreparedEvent & {
|
||||
taskSlug?: string;
|
||||
};
|
||||
|
||||
export type SpanDetail = {
|
||||
// ============================================================================
|
||||
// Core Identity & Structure
|
||||
// ============================================================================
|
||||
spanId: string; // Tree structure, span identification
|
||||
parentId: string | null; // Tree hierarchy
|
||||
message: string; // Displayed as span title
|
||||
|
||||
// ============================================================================
|
||||
// Status & State
|
||||
// ============================================================================
|
||||
isError: boolean; // Error status display, filtering, status icons
|
||||
isPartial: boolean; // In-progress status display, timeline calculations
|
||||
isCancelled: boolean; // Cancelled status display, status determination
|
||||
level: TaskEventLevel; // Text styling, timeline rendering decisions
|
||||
|
||||
// ============================================================================
|
||||
// Timing
|
||||
// ============================================================================
|
||||
startTime: Date; // Timeline calculations, display
|
||||
duration: number; // Timeline width, duration display, calculations
|
||||
|
||||
// ============================================================================
|
||||
// Content & Display
|
||||
// ============================================================================
|
||||
events: SpanEvents; // Timeline events, SpanEvents component
|
||||
style: TaskEventStyle; // Icons, variants, accessories (RunIcon, SpanTitle)
|
||||
properties: Record<string, unknown> | string | number | boolean | null | undefined; // Displayed as JSON in span properties (CodeBlock)
|
||||
resourceProperties?: Record<string, unknown> | string | number | boolean | null | undefined; // Displayed as JSON in span resource properties (CodeBlock)
|
||||
|
||||
// ============================================================================
|
||||
// Entity & Relationships
|
||||
// ============================================================================
|
||||
entity: {
|
||||
// Used for entity type switching in SpanEntity
|
||||
type: string | undefined;
|
||||
id: string | undefined;
|
||||
metadata: string | undefined;
|
||||
};
|
||||
|
||||
metadata: any; // Used by SpanPresenter for entity processing
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Span and Link Types
|
||||
// ============================================================================
|
||||
|
||||
export type SpanSummaryCommon = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
runId: string;
|
||||
data: {
|
||||
message: string;
|
||||
events: SpanEvents;
|
||||
startTime: Date;
|
||||
duration: number;
|
||||
isError: boolean;
|
||||
isPartial: boolean;
|
||||
isCancelled: boolean;
|
||||
level: NonNullable<CreateEventInput["level"]>;
|
||||
attemptNumber?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SpanSummary = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
runId: string;
|
||||
data: {
|
||||
message: string;
|
||||
style: TaskEventStyle;
|
||||
events: SpanEvents;
|
||||
startTime: Date;
|
||||
duration: number;
|
||||
isError: boolean;
|
||||
isPartial: boolean;
|
||||
isCancelled: boolean;
|
||||
isDebug: boolean;
|
||||
level: NonNullable<CreateEventInput["level"]>;
|
||||
attemptNumber?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SpanOverride = {
|
||||
isCancelled?: boolean;
|
||||
isError?: boolean;
|
||||
duration?: number;
|
||||
events?: SpanEvents;
|
||||
};
|
||||
|
||||
export type TraceSummary = {
|
||||
rootSpan: SpanSummary;
|
||||
spans: Array<SpanSummary>;
|
||||
overridesBySpanId?: Record<string, SpanOverride>;
|
||||
/** Set when a subtree fetch hit the row cap before collecting all descendants. */
|
||||
isTruncated?: boolean;
|
||||
};
|
||||
|
||||
export type SpanDetailedSummary = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
runId: string;
|
||||
data: {
|
||||
message: string;
|
||||
taskSlug?: string;
|
||||
events: SpanEvents;
|
||||
startTime: Date;
|
||||
duration: number;
|
||||
isError: boolean;
|
||||
isPartial: boolean;
|
||||
isCancelled: boolean;
|
||||
level: NonNullable<CreateEventInput["level"]>;
|
||||
attemptNumber?: number;
|
||||
properties?: Attributes;
|
||||
};
|
||||
children: Array<SpanDetailedSummary>;
|
||||
};
|
||||
|
||||
// A single trace event for the streaming export path (the "Download trace"
|
||||
// feature). Deliberately flat and self-contained: it carries its own parent ref
|
||||
// so hierarchy is reconstructable downstream without ever building a tree. Used
|
||||
// by `streamTraceEvents`, which yields these one at a time so an arbitrarily
|
||||
// large trace is never fully resident in memory.
|
||||
export type StreamedTraceEvent = {
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
startTime: Date;
|
||||
durationNs: number;
|
||||
level: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
// Span attributes/properties as a raw JSON string, emitted verbatim (the
|
||||
// ClickHouse store already materialises it as text — no per-row parse).
|
||||
propertiesText: string;
|
||||
};
|
||||
|
||||
export type TraceDetailedSummary = {
|
||||
traceId: string;
|
||||
rootSpan: SpanDetailedSummary;
|
||||
/** Set when a fetch hit the row cap before collecting all spans. */
|
||||
isTruncated?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Event Repository Interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Interface for the EventRepository class.
|
||||
* Defines the public API for managing task events, traces, and spans.
|
||||
*/
|
||||
export interface IEventRepository {
|
||||
maximumLiveReloadingSetting: number;
|
||||
// Event insertion methods
|
||||
insertMany(events: CreateEventInput[]): void;
|
||||
insertManyImmediate(events: CreateEventInput[]): Promise<void>;
|
||||
insertManyMetrics(rows: MetricsV1Input[]): void;
|
||||
|
||||
// Run event completion methods
|
||||
completeSuccessfulRunEvent(params: { run: CompleteableTaskRun; endTime?: Date }): Promise<void>;
|
||||
|
||||
completeCachedRunEvent(params: {
|
||||
run: CompleteableTaskRun;
|
||||
blockedRun: CompleteableTaskRun;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
spanCreatedAt: Date;
|
||||
isError: boolean;
|
||||
endTime?: Date;
|
||||
}): Promise<void>;
|
||||
|
||||
completeFailedRunEvent(params: {
|
||||
run: CompleteableTaskRun;
|
||||
endTime?: Date;
|
||||
exception: { message?: string; type?: string; stacktrace?: string };
|
||||
}): Promise<void>;
|
||||
|
||||
completeExpiredRunEvent(params: {
|
||||
run: CompleteableTaskRun;
|
||||
endTime?: Date;
|
||||
ttl: string;
|
||||
}): Promise<void>;
|
||||
|
||||
createAttemptFailedRunEvent(params: {
|
||||
run: CompleteableTaskRun;
|
||||
endTime?: Date;
|
||||
attemptNumber: number;
|
||||
exception: { message?: string; type?: string; stacktrace?: string };
|
||||
}): Promise<void>;
|
||||
|
||||
cancelRunEvent(params: {
|
||||
reason: string;
|
||||
run: CompleteableTaskRun;
|
||||
cancelledAt: Date;
|
||||
}): Promise<void>;
|
||||
|
||||
// Query methods
|
||||
getTraceSummary(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<TraceSummary | undefined>;
|
||||
|
||||
/** Fetch the anchor span, its ancestors (for override propagation), and all descendants. */
|
||||
getTraceSubtreeSummary(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
anchorSpanId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<TraceSummary | undefined>;
|
||||
|
||||
getTraceDetailedSummary(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<TraceDetailedSummary | undefined>;
|
||||
|
||||
/** Fetch the anchor span subtree as a detailed hierarchical trace rooted at anchorSpanId. */
|
||||
getTraceDetailedSubtreeSummary(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
anchorSpanId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<TraceDetailedSummary | undefined>;
|
||||
|
||||
// Streams a trace's events in start_time order, one at a time, without ever
|
||||
// materialising the full result set or a tree. Powers the streaming trace
|
||||
// export so arbitrarily large traces download with bounded memory.
|
||||
streamTraceEvents(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): AsyncIterable<StreamedTraceEvent>;
|
||||
|
||||
getRunEvents(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
traceId: string,
|
||||
runId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date
|
||||
): Promise<RunPreparedEvent[]>;
|
||||
|
||||
getSpan(
|
||||
storeTable: TaskEventStoreTable,
|
||||
environmentId: string,
|
||||
spanId: string,
|
||||
traceId: string,
|
||||
startCreatedAt: Date,
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<SpanDetail | undefined>;
|
||||
|
||||
// Event recording methods
|
||||
recordEvent(
|
||||
message: string,
|
||||
options: TraceEventOptions & { duration?: number; parentId?: string }
|
||||
): Promise<void>;
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { env } from "~/env.server";
|
||||
import { eventRepository } from "./eventRepository.server";
|
||||
import { type IEventRepository, type TraceEventOptions } from "./eventRepository.types";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runStore } from "../runStore.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG } from "../featureFlags";
|
||||
import { flag } from "../featureFlags.server";
|
||||
import { getTaskEventStore } from "../taskEventStore.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
|
||||
export const EVENT_STORE_TYPES = {
|
||||
POSTGRES: "postgres",
|
||||
CLICKHOUSE: "clickhouse",
|
||||
CLICKHOUSE_V2: "clickhouse_v2",
|
||||
} as const;
|
||||
|
||||
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];
|
||||
/**
|
||||
* Async variant of {@link resolveEventRepositoryForStore}. Awaits the factory's
|
||||
* registry readiness before returning the ClickHouse event repository; for
|
||||
* non-ClickHouse stores (e.g. the "taskEvent" DB default for Postgres-backed
|
||||
* runs) it returns the Prisma event repository without ever touching the
|
||||
* factory — so the factory never needs to know about Postgres.
|
||||
*/
|
||||
export async function getEventRepositoryForStore(
|
||||
store: string,
|
||||
organizationId: string
|
||||
): Promise<IEventRepository> {
|
||||
if (store !== EVENT_STORE_TYPES.CLICKHOUSE && store !== EVENT_STORE_TYPES.CLICKHOUSE_V2) {
|
||||
return eventRepository;
|
||||
}
|
||||
const { repository } = await clickhouseFactory.getEventRepositoryForOrganization(
|
||||
store,
|
||||
organizationId
|
||||
);
|
||||
return repository;
|
||||
}
|
||||
|
||||
export async function getConfiguredEventRepository(
|
||||
organizationId: string
|
||||
): Promise<{ repository: IEventRepository; store: EventStoreType }> {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
featureFlags: true,
|
||||
},
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found when configuring event repository");
|
||||
}
|
||||
|
||||
// resolveTaskEventRepositoryFlag checks:
|
||||
// 1. organization.featureFlags (highest priority)
|
||||
// 2. global feature flags (via flags() function)
|
||||
// 3. env.EVENT_REPOSITORY_DEFAULT_STORE (fallback)
|
||||
const taskEventStore = await resolveTaskEventRepositoryFlag(
|
||||
(organization.featureFlags as Record<string, unknown> | null) ?? undefined
|
||||
);
|
||||
|
||||
if (taskEventStore === EVENT_STORE_TYPES.CLICKHOUSE_V2) {
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
|
||||
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE_V2 };
|
||||
}
|
||||
|
||||
if (taskEventStore === EVENT_STORE_TYPES.CLICKHOUSE) {
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
|
||||
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE };
|
||||
}
|
||||
|
||||
return { repository: eventRepository, store: EVENT_STORE_TYPES.POSTGRES };
|
||||
}
|
||||
|
||||
export async function getEventRepository(
|
||||
organizationId: string,
|
||||
featureFlags: Record<string, unknown> | undefined,
|
||||
parentStore: string | undefined
|
||||
): Promise<{ repository: IEventRepository; store: string }> {
|
||||
const taskEventStore = parentStore ?? (await resolveTaskEventRepositoryFlag(featureFlags));
|
||||
|
||||
// Non-ClickHouse stores (e.g. the "taskEvent" DB default for Postgres-backed
|
||||
// runs, or the legacy "postgres" value) resolve to the Prisma event repo.
|
||||
if (
|
||||
taskEventStore !== EVENT_STORE_TYPES.CLICKHOUSE &&
|
||||
taskEventStore !== EVENT_STORE_TYPES.CLICKHOUSE_V2
|
||||
) {
|
||||
return { repository: eventRepository, store: getTaskEventStore() };
|
||||
}
|
||||
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
|
||||
|
||||
switch (taskEventStore) {
|
||||
case EVENT_STORE_TYPES.CLICKHOUSE_V2: {
|
||||
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE_V2 };
|
||||
}
|
||||
case EVENT_STORE_TYPES.CLICKHOUSE: {
|
||||
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE };
|
||||
}
|
||||
default: {
|
||||
return { repository: eventRepository, store: getTaskEventStore() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getV3EventRepository(
|
||||
organizationId: string,
|
||||
parentStore: string | undefined
|
||||
): Promise<{ repository: IEventRepository; store: string }> {
|
||||
if (typeof parentStore === "string") {
|
||||
// Support legacy Postgres store for self-hosters and runs persisted with a
|
||||
// non-ClickHouse store — fall back to the Prisma-based event repository.
|
||||
if (
|
||||
parentStore !== EVENT_STORE_TYPES.CLICKHOUSE &&
|
||||
parentStore !== EVENT_STORE_TYPES.CLICKHOUSE_V2
|
||||
) {
|
||||
return { repository: eventRepository, store: parentStore };
|
||||
}
|
||||
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization(parentStore, organizationId);
|
||||
return { repository: resolvedRepository, store: parentStore };
|
||||
}
|
||||
|
||||
if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse_v2") {
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization("clickhouse_v2", organizationId);
|
||||
return { repository: resolvedRepository, store: "clickhouse_v2" };
|
||||
} else if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse") {
|
||||
const { repository: resolvedRepository } =
|
||||
await clickhouseFactory.getEventRepositoryForOrganization("clickhouse", organizationId);
|
||||
return { repository: resolvedRepository, store: "clickhouse" };
|
||||
} else {
|
||||
return { repository: eventRepository, store: getTaskEventStore() };
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTaskEventRepositoryFlag(
|
||||
featureFlags: Record<string, unknown> | undefined
|
||||
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
|
||||
const flagResult = await flag({
|
||||
key: FEATURE_FLAG.taskEventRepository,
|
||||
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
overrides: featureFlags,
|
||||
});
|
||||
|
||||
if (flagResult === "clickhouse_v2") {
|
||||
return "clickhouse_v2";
|
||||
}
|
||||
|
||||
if (flagResult === "clickhouse") {
|
||||
return "clickhouse";
|
||||
}
|
||||
|
||||
return flagResult;
|
||||
}
|
||||
|
||||
export async function recordRunDebugLog(
|
||||
runId: string,
|
||||
message: string,
|
||||
options: Omit<TraceEventOptions, "environment" | "taskSlug" | "startTime"> & {
|
||||
duration?: number;
|
||||
parentId?: string;
|
||||
startTime?: Date;
|
||||
}
|
||||
): Promise<
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
code: "RUN_NOT_FOUND" | "FAILED_TO_RECORD_EVENT";
|
||||
error?: unknown;
|
||||
}
|
||||
> {
|
||||
if (env.EVENT_REPOSITORY_DEBUG_LOGS_DISABLED) {
|
||||
// drop debug events silently
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return recordRunEvent(runId, message, {
|
||||
...options,
|
||||
attributes: {
|
||||
...options?.attributes,
|
||||
isDebug: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function recordRunEvent(
|
||||
runId: string,
|
||||
message: string,
|
||||
options: Omit<TraceEventOptions, "environment" | "taskSlug" | "startTime"> & {
|
||||
duration?: number;
|
||||
parentId?: string;
|
||||
startTime?: Date;
|
||||
}
|
||||
): Promise<
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
code: "RUN_NOT_FOUND" | "FAILED_TO_RECORD_EVENT";
|
||||
error?: unknown;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const foundRun = await findRunForEventCreation(runId);
|
||||
|
||||
if (!foundRun) {
|
||||
logger.error("Failed to find run for event creation", { runId });
|
||||
return {
|
||||
success: false,
|
||||
code: "RUN_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
const $eventRepository = await getEventRepositoryForStore(
|
||||
foundRun.taskEventStore,
|
||||
foundRun.runtimeEnvironment.organizationId
|
||||
);
|
||||
|
||||
const { attributes, startTime, ...optionsRest } = options;
|
||||
|
||||
await $eventRepository.recordEvent(message, {
|
||||
environment: foundRun.runtimeEnvironment,
|
||||
taskSlug: foundRun.taskIdentifier,
|
||||
context: foundRun.traceContext as Record<string, string | undefined>,
|
||||
attributes: {
|
||||
runId: foundRun.friendlyId,
|
||||
...attributes,
|
||||
},
|
||||
startTime: BigInt((startTime?.getTime() ?? Date.now()) * 1_000_000),
|
||||
...optionsRest,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to record event for run", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
runId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: "FAILED_TO_RECORD_EVENT",
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function findRunForEventCreation(runId: string) {
|
||||
const foundRun = await runStore.findRun(
|
||||
{
|
||||
id: runId,
|
||||
},
|
||||
{
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
traceContext: true,
|
||||
taskEventStore: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
if (!foundRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
|
||||
foundRun.runtimeEnvironmentId
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
// Run exists but its environment could not be resolved (e.g. a lagging replica
|
||||
// under split); distinguish this from a genuinely missing run.
|
||||
logger.warn("Run found but environment unresolved for event creation", {
|
||||
runId,
|
||||
runtimeEnvironmentId: foundRun.runtimeEnvironmentId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...foundRun, runtimeEnvironment: environment };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
|
||||
|
||||
/**
|
||||
* Replacement string we substitute for any attribute value that contains
|
||||
* a lone UTF-16 surrogate. JSON-safe, distinctly recognisable in logs and
|
||||
* the dashboard so operators can spot affected rows.
|
||||
*/
|
||||
export const INVALID_UTF16_SENTINEL = "[invalid-utf16]";
|
||||
|
||||
/**
|
||||
* ClickHouse's `JSON(max_dynamic_paths)` column fits each bare-integer
|
||||
* JSON token into Int64 (signed) or UInt64 (unsigned). Bare integers
|
||||
* outside `[-2^63, 2^64 - 1]` are rejected with `INCORRECT_DATA` (no
|
||||
* silent fallback to Float64). `JSON.stringify` emits any integer-valued
|
||||
* Number with `|value| < 1e21` as a bare integer (no exponent), so any
|
||||
* JS Number above ~9.2e18 that *happens* to be integer-valued lands on
|
||||
* the wire as a token CH cannot accept.
|
||||
*
|
||||
* The fix: replace such Numbers with their string form. CH's dynamic
|
||||
* JSON column accepts a `String` subtype on the same path, so the row
|
||||
* inserts cleanly on retry. The numeric value was already
|
||||
* precision-lossy upstream (JS Number can't represent integers above
|
||||
* 2^53 faithfully), so type-flipping to string is information-preserving
|
||||
* relative to what arrived.
|
||||
*
|
||||
* Float-valued numbers (including very large ones like `1e25`) serialise
|
||||
* with an exponent and are accepted by CH at any magnitude, so they're
|
||||
* left alone.
|
||||
*/
|
||||
const UINT64_MAX = 18446744073709551615n;
|
||||
const INT64_MIN = -9223372036854775808n;
|
||||
|
||||
function isUnsafeJsonInteger(value: number): boolean {
|
||||
if (!Number.isFinite(value)) return false;
|
||||
if (!Number.isInteger(value)) return false;
|
||||
// JSON.stringify emits integer-valued Numbers as bare integer tokens
|
||||
// (no exponent) only while `|value| < 1e21`; at or above that
|
||||
// threshold `Number.prototype.toString` switches to exponential form,
|
||||
// which CH accepts as Float64 at any magnitude. So the dangerous band
|
||||
// is strictly between the Int64/UInt64 boundary and 1e21.
|
||||
if (Math.abs(value) >= 1e21) return false;
|
||||
// Compare via BigInt for exactness. The Number literal 18446744073709551615
|
||||
// is rounded to 2**64 in float64 (the float spacing near 2^64 is 2048), so a
|
||||
// direct `value > 18446744073709551615` would miss a Number whose float64
|
||||
// value is exactly 2**64 — `JSON.stringify` of that emits
|
||||
// "18446744073709552000", which exceeds UInt64.MAX and ClickHouse rejects.
|
||||
// `BigInt(value)` is safe here because we already gated on Number.isInteger.
|
||||
const asBigInt = BigInt(value);
|
||||
return asBigInt > UINT64_MAX || asBigInt < INT64_MIN;
|
||||
}
|
||||
|
||||
export type SanitizeResult = {
|
||||
/** How many rows had at least one string field replaced. */
|
||||
rowsTouched: number;
|
||||
/** Total count of string fields replaced across all sanitized rows. */
|
||||
fieldsSanitized: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recognises ClickHouse's "Cannot parse JSON object" rejection — the
|
||||
* deterministic-failure class our sanitizer is designed for. Bubbles up
|
||||
* from `@clickhouse/client` as an `InsertError` whose `.message` retains
|
||||
* the original ClickHouse error text.
|
||||
*/
|
||||
export function isClickHouseJsonParseError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
const message =
|
||||
typeof err === "object" && err !== null && "message" in err
|
||||
? String((err as { message?: unknown }).message ?? "")
|
||||
: String(err);
|
||||
return message.includes("Cannot parse JSON object");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the row index ClickHouse reported as the first to fail
|
||||
* (`(at row N)`). Returns `null` if the message doesn't include one —
|
||||
* caller should treat that as "sanitize from row 0".
|
||||
*/
|
||||
export function parseRowNumberFromError(errorMessage: string): number | null {
|
||||
const match = errorMessage.match(/at row (\d+)/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks `value` recursively and replaces any string leaf that contains a
|
||||
* lone UTF-16 surrogate with `INVALID_UTF16_SENTINEL`. Mutates objects
|
||||
* and arrays in place; primitives are returned unchanged.
|
||||
*
|
||||
* Caller passes anything: a row object, a single field, an unknown JSON
|
||||
* payload. The walker doesn't depend on the row's schema — it sanitizes
|
||||
* every string in the structure, which is exactly what ClickHouse cares
|
||||
* about when parsing the row's JSON form.
|
||||
*/
|
||||
export function sanitizeUnknownInPlace(value: unknown): { value: unknown; fixed: number } {
|
||||
if (typeof value === "string") {
|
||||
// `detectBadJsonStrings` works on JSON-escaped text — feed it the
|
||||
// serialized form so any lone UTF-16 surrogate in the JS string is
|
||||
// emitted as a `\uXXXX` escape it can spot. Valid surrogate pairs
|
||||
// (e.g. emoji) are emitted as raw characters by JSON.stringify and
|
||||
// exit at the function's fast path.
|
||||
if (detectBadJsonStrings(JSON.stringify(value))) {
|
||||
return { value: INVALID_UTF16_SENTINEL, fixed: 1 };
|
||||
}
|
||||
return { value, fixed: 0 };
|
||||
}
|
||||
|
||||
if (typeof value === "number" && isUnsafeJsonInteger(value)) {
|
||||
return { value: String(value), fixed: 1 };
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let fixed = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const result = sanitizeUnknownInPlace(value[i]);
|
||||
value[i] = result.value;
|
||||
fixed += result.fixed;
|
||||
}
|
||||
return { value, fixed };
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
let fixed = 0;
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const k of Object.keys(obj)) {
|
||||
const result = sanitizeUnknownInPlace(obj[k]);
|
||||
obj[k] = result.value;
|
||||
fixed += result.fixed;
|
||||
}
|
||||
return { value, fixed };
|
||||
}
|
||||
|
||||
return { value, fixed: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes every row in `rows`, mutating each in place so callers can
|
||||
* hand the same array to the retry insert.
|
||||
*
|
||||
* Rationale for scanning the whole batch (instead of starting from the
|
||||
* row index ClickHouse reports): `at row N` semantics under
|
||||
* `input_format_parallel_parsing` aren't well-defined — N can be
|
||||
* chunk-relative rather than batch-global, and 0-vs-1 indexing differs
|
||||
* between formats. Whole-batch scanning is robust to those quirks and
|
||||
* also catches multiple bad rows in one pass (so a single retry covers
|
||||
* the entire failure even if more than one row is poisoned).
|
||||
*
|
||||
* The cost is bounded: this only runs on the rare ClickHouse-rejection
|
||||
* path, and `detectBadJsonStrings` exits in O(1) for clean strings
|
||||
* (the fast `indexOf("\\u")` check), so healthy attributes are effectively
|
||||
* free even when included in the walk.
|
||||
*/
|
||||
export function sanitizeRows<T extends object>(rows: T[]): SanitizeResult {
|
||||
const result: SanitizeResult = { rowsTouched: 0, fieldsSanitized: 0 };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const { fixed } = sanitizeUnknownInPlace(rows[i]);
|
||||
if (fixed > 0) {
|
||||
result.rowsTouched++;
|
||||
result.fieldsSanitized += fixed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { formatDurationNanoseconds } from "@trigger.dev/core/v3/utils/durations";
|
||||
import type { StreamedTraceEvent } from "./eventRepository.types";
|
||||
|
||||
// Lines are batched into ~64KB chunks before being yielded to the gzip stream:
|
||||
// per-line chunks make `Readable.from` ~10x slower, while chunks this size keep
|
||||
// the pipe fed yet stay small enough to release the event loop frequently.
|
||||
const DEFAULT_FLUSH_BYTES = 64 * 1024;
|
||||
|
||||
export type TraceExportContext = {
|
||||
runFriendlyId: string;
|
||||
traceId: string;
|
||||
taskIdentifier?: string;
|
||||
/** Absolute dashboard URL for the run (used by formats that link out). */
|
||||
runUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A trace export format. `formatEvent` renders one {@link StreamedTraceEvent} to
|
||||
* a string; `header`/`footer` bookend the stream. Formats are intentionally
|
||||
* stateless across events so the export stays O(1) memory — see
|
||||
* {@link streamTraceExport}.
|
||||
*/
|
||||
export type TraceExportFormat = {
|
||||
name: TraceExportFormatName;
|
||||
extension: string;
|
||||
header?: (ctx: TraceExportContext) => string;
|
||||
formatEvent: (event: StreamedTraceEvent, ctx: TraceExportContext) => string;
|
||||
footer?: (ctx: TraceExportContext) => string;
|
||||
};
|
||||
|
||||
export type TraceExportFormatName = "log" | "jsonl" | "markdown";
|
||||
|
||||
/**
|
||||
* Streams a trace export by piping events through a {@link TraceExportFormat}.
|
||||
* Batches output into ~`flushBytes` chunks and releases the event loop between
|
||||
* flushes; holds nothing across events but the buffer, so an arbitrarily large
|
||||
* trace exports in bounded memory regardless of format.
|
||||
*/
|
||||
export async function* streamTraceExport(
|
||||
events: AsyncIterable<StreamedTraceEvent>,
|
||||
format: TraceExportFormat,
|
||||
ctx: TraceExportContext,
|
||||
options: { flushBytes?: number } = {}
|
||||
): AsyncGenerator<string> {
|
||||
const flushBytes = options.flushBytes ?? DEFAULT_FLUSH_BYTES;
|
||||
|
||||
let buffer = format.header ? format.header(ctx) : "";
|
||||
|
||||
for await (const event of events) {
|
||||
buffer += format.formatEvent(event, ctx);
|
||||
if (buffer.length >= flushBytes) {
|
||||
yield buffer;
|
||||
buffer = "";
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
if (format.footer) {
|
||||
buffer += format.footer(ctx);
|
||||
}
|
||||
|
||||
if (buffer.length > 0) {
|
||||
yield buffer;
|
||||
}
|
||||
}
|
||||
|
||||
function hasProperties(propertiesText: string): boolean {
|
||||
const trimmed = propertiesText.trim();
|
||||
return trimmed.length > 0 && trimmed !== "{}";
|
||||
}
|
||||
|
||||
// For error events, pull the error message out of the properties so formats can
|
||||
// surface it inline instead of burying it in the JSON blob. Only parses when the
|
||||
// event is actually an error (rare), so the common path stays parse-free. Handles
|
||||
// both trigger.dev's `error.*` shape and OTel's `exception.*` shape; the full
|
||||
// object (incl. stacktrace) still rides along in the properties.
|
||||
function errorMessage(event: StreamedTraceEvent): string | undefined {
|
||||
if (!event.isError || !hasProperties(event.propertiesText)) return undefined;
|
||||
try {
|
||||
const props = JSON.parse(event.propertiesText) as {
|
||||
error?: { message?: unknown };
|
||||
exception?: { message?: unknown };
|
||||
};
|
||||
const message = props.error?.message ?? props.exception?.message;
|
||||
return typeof message === "string" && message.length > 0
|
||||
? message.replace(/\s+/g, " ").trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function lineage(event: StreamedTraceEvent): string {
|
||||
return event.parentSpanId ? `${event.spanId} ← ${event.parentSpanId}` : event.spanId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// log — flat, chronological, grep-friendly. One line per event (+ a props line).
|
||||
// ---------------------------------------------------------------------------
|
||||
const logFormat: TraceExportFormat = {
|
||||
name: "log",
|
||||
extension: "txt",
|
||||
formatEvent(event) {
|
||||
const time = event.startTime.toISOString();
|
||||
const level = event.level.padEnd(5);
|
||||
const errMsg = errorMessage(event);
|
||||
const status = event.isError ? (errMsg ? ` [ERROR: ${errMsg}]` : " [ERROR]") : "";
|
||||
const duration =
|
||||
event.durationNs > 0 ? ` (${formatDurationNanoseconds(event.durationNs)})` : "";
|
||||
|
||||
let out = `${time} ${level} [${lineage(event)}] ${event.message}${status}${duration}\n`;
|
||||
if (hasProperties(event.propertiesText)) {
|
||||
out += ` props: ${event.propertiesText.trim()}\n`;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsonl — one JSON object per line, properties inlined as a nested object.
|
||||
// ---------------------------------------------------------------------------
|
||||
const jsonlFormat: TraceExportFormat = {
|
||||
name: "jsonl",
|
||||
extension: "jsonl",
|
||||
formatEvent(event) {
|
||||
let properties: unknown = undefined;
|
||||
if (hasProperties(event.propertiesText)) {
|
||||
try {
|
||||
properties = JSON.parse(event.propertiesText);
|
||||
} catch {
|
||||
properties = event.propertiesText;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
JSON.stringify({
|
||||
time: event.startTime.toISOString(),
|
||||
level: event.level,
|
||||
spanId: event.spanId,
|
||||
parentSpanId: event.parentSpanId || undefined,
|
||||
message: event.message,
|
||||
durationNs: event.durationNs,
|
||||
isError: event.isError || undefined,
|
||||
errorMessage: errorMessage(event),
|
||||
properties,
|
||||
}) + "\n"
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// markdown — AI-friendly: YAML frontmatter (ids, task, dashboard URL) + a
|
||||
// scannable table, one row per event. Properties stay (inline code) so the
|
||||
// export isn't lossy; a column-friendly cell escaper keeps the table intact.
|
||||
// ---------------------------------------------------------------------------
|
||||
function mdCell(value: string): string {
|
||||
// Pipes and newlines would break the table row; escape/flatten them. (GFM
|
||||
// treats `\|` inside a table cell — including code spans — as a literal pipe.)
|
||||
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
const markdownFormat: TraceExportFormat = {
|
||||
name: "markdown",
|
||||
extension: "md",
|
||||
header(ctx) {
|
||||
const lines = ["---", `run: ${ctx.runFriendlyId}`, `trace: ${ctx.traceId}`];
|
||||
if (ctx.taskIdentifier) lines.push(`task: ${ctx.taskIdentifier}`);
|
||||
if (ctx.runUrl) lines.push(`url: ${ctx.runUrl}`);
|
||||
lines.push("---", "", `# Trace for ${ctx.runFriendlyId}`, "");
|
||||
if (ctx.runUrl) {
|
||||
lines.push(`[View in dashboard](${ctx.runUrl})`, "");
|
||||
}
|
||||
lines.push(
|
||||
"| time | level | event | duration | span ← parent | properties |",
|
||||
"| --- | --- | --- | --- | --- | --- |"
|
||||
);
|
||||
return lines.join("\n") + "\n";
|
||||
},
|
||||
formatEvent(event) {
|
||||
const time = event.startTime.toISOString();
|
||||
const level = event.isError ? `${event.level} ❌` : event.level;
|
||||
const duration = event.durationNs > 0 ? formatDurationNanoseconds(event.durationNs) : "—";
|
||||
const lineage = event.parentSpanId ? `${event.spanId} ← ${event.parentSpanId}` : event.spanId;
|
||||
const errMsg = errorMessage(event);
|
||||
const eventCell = errMsg ? `${event.message} — ${errMsg}` : event.message;
|
||||
const properties = hasProperties(event.propertiesText)
|
||||
? "`" + mdCell(event.propertiesText.trim()) + "`"
|
||||
: "—";
|
||||
|
||||
return `| ${time} | ${level} | ${mdCell(eventCell)} | ${duration} | \`${lineage}\` | ${properties} |\n`;
|
||||
},
|
||||
};
|
||||
|
||||
const FORMATS: Record<TraceExportFormatName, TraceExportFormat> = {
|
||||
log: logFormat,
|
||||
jsonl: jsonlFormat,
|
||||
markdown: markdownFormat,
|
||||
};
|
||||
|
||||
/** Resolve a `?format=` value to a format, defaulting to `log`. */
|
||||
export function getTraceExportFormat(name: string | null | undefined): TraceExportFormat {
|
||||
if (name && Object.prototype.hasOwnProperty.call(FORMATS, name)) {
|
||||
return FORMATS[name as TraceExportFormatName];
|
||||
}
|
||||
return logFormat;
|
||||
}
|
||||
Reference in New Issue
Block a user