chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
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;
}