import type { Tracer } from "@opentelemetry/api"; import { trace } from "@opentelemetry/api"; import { SemanticInternalAttributes } from "@trigger.dev/core/v3"; import type { ExportLogsServiceRequest, ExportMetricsServiceRequest, ExportTraceServiceRequest, ResourceMetrics, } from "@trigger.dev/otlp-importer"; import { ExportLogsServiceResponse, ExportMetricsServiceResponse, ExportTraceServiceResponse, } from "@trigger.dev/otlp-importer"; import type { MetricsV1Input } from "@internal/clickhouse"; import { getMeter, type Counter, type Histogram, type Meter } from "@internal/tracing"; import { logger } from "~/services/logger.server"; import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { eventRepository } from "./eventRepository/eventRepository.server"; import type { CreateEventInput, IEventRepository } from "./eventRepository/eventRepository.types"; import { startSpan } from "./tracing.server"; import { enrichCreatableEvents } from "./utils/enrichCreatableEvents.server"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { convertLogsToCreateableEvents, convertMetricsToClickhouseRows, convertSpansToCreateableEvents, isBoolValue, isStringValue, } from "./otlpTransform.server"; import os from "node:os"; import { getOtlpWorkerPool } from "./otlpWorkerPool.server"; import { llmPricingRegistry, subscribeToPricingReload, waitForLlmPricingReady, } from "./llmPricingRegistry.server"; // When enabled, decode+convert+enrich run in a worker pool; the main thread keeps the single // consolidated insert path (batching/part-count unchanged). Off = today's single-thread path. export const otlpTransformWorkerPoolEnabled = env.OTEL_TRANSFORM_WORKER_POOL_ENABLED; // Always at least 1 worker: a 0/negative override must not silently disable the pool while the // flag is on (that would hang every raw-export request on an empty pool). const OTEL_TRANSFORM_WORKER_POOL_SIZE = Math.max( 1, env.OTEL_TRANSFORM_WORKER_POOL_SIZE ?? (os.cpus()?.length ?? 2) - 2 ); type OTLPExporterConfig = { clickhouseFactory: ClickhouseFactory; verbose: boolean; spanAttributeValueLengthLimit: number; // Inject in tests; defaults to the global provider. Instruments are no-op when metrics are off. meter?: Meter; }; class OTLPExporter { private _tracer: Tracer; private readonly _clickhouseFactory: ClickhouseFactory; private readonly _verbose: boolean; private readonly _spanAttributeValueLengthLimit: number; #pricingSubscribed = false; private readonly _meter: Meter; private readonly _ingestRequests: Counter; private readonly _ingestBytes: Counter; private readonly _ingestDuration: Histogram; private readonly _eventsProduced: Counter; private readonly _metricRowsProduced: Counter; constructor(config: OTLPExporterConfig) { this._tracer = trace.getTracer("otlp-exporter"); this._clickhouseFactory = config.clickhouseFactory; this._verbose = config.verbose; this._spanAttributeValueLengthLimit = config.spanAttributeValueLengthLimit; this._meter = config.meter ?? getMeter("ingest"); this._ingestRequests = this._meter.createCounter("ingest.requests", { description: "OTLP export calls received, by signal / path / outcome", unit: "requests", }); this._ingestBytes = this._meter.createCounter("ingest.bytes", { description: "Compressed protobuf payload bytes received", unit: "By", }); this._ingestDuration = this._meter.createHistogram("ingest.duration", { description: "End-to-end time to handle an OTLP export call", unit: "ms", }); this._eventsProduced = this._meter.createCounter("ingest.events.produced", { description: "Task events produced from spans/logs after filter + convert", unit: "events", }); this._metricRowsProduced = this._meter.createCounter("ingest.metric_rows.produced", { description: "ClickHouse metric rows produced from OTLP metrics", unit: "rows", }); } // One helper for both the worker (mode="worker") and inline (mode="inline") paths. Called once // per export call, so building the small attribute objects here is not a hot-path allocation. #recordIngest(kind: string, mode: string, outcome: string, startedAt: number): void { this._ingestRequests.add(1, { kind, mode, outcome }); this._ingestDuration.record(Date.now() - startedAt, { kind, mode }); } async exportTraces(request: ExportTraceServiceRequest): Promise { return await startSpan(this._tracer, "exportTraces", async (span) => { const startedAt = Date.now(); try { this.#logExportTracesVerbose(request); const eventsWithStores = this.#filterResourceSpans(request.resourceSpans).flatMap( (resourceSpan) => { return convertSpansToCreateableEvents( resourceSpan, this._spanAttributeValueLengthLimit, env.EVENT_REPOSITORY_DEFAULT_STORE ); } ); const eventCount = await this.#exportEvents(eventsWithStores); span.setAttribute("event_count", eventCount); this._eventsProduced.add(eventCount, { kind: "traces" }); this.#recordIngest("traces", "inline", "ok", startedAt); return ExportTraceServiceResponse.create(); } catch (error) { this.#recordIngest("traces", "inline", "error", startedAt); throw error; } }); } async exportMetrics(request: ExportMetricsServiceRequest): Promise { return await startSpan(this._tracer, "exportMetrics", async (span) => { const startedAt = Date.now(); try { const rows = this.#filterResourceMetrics(request.resourceMetrics).flatMap( (resourceMetrics) => convertMetricsToClickhouseRows(resourceMetrics, this._spanAttributeValueLengthLimit) ); span.setAttribute("metric_row_count", rows.length); if (rows.length > 0) { await this.#exportMetricRows(rows); } this._metricRowsProduced.add(rows.length, { kind: "metrics" }); this.#recordIngest("metrics", "inline", "ok", startedAt); return ExportMetricsServiceResponse.create(); } catch (error) { this.#recordIngest("metrics", "inline", "error", startedAt); throw error; } }); } async exportLogs(request: ExportLogsServiceRequest): Promise { return await startSpan(this._tracer, "exportLogs", async (span) => { const startedAt = Date.now(); try { this.#logExportLogsVerbose(request); const eventsWithStores = this.#filterResourceLogs(request.resourceLogs).flatMap( (resourceLog) => { return convertLogsToCreateableEvents( resourceLog, this._spanAttributeValueLengthLimit, env.EVENT_REPOSITORY_DEFAULT_STORE ); } ); const eventCount = await this.#exportEvents(eventsWithStores); span.setAttribute("event_count", eventCount); this._eventsProduced.add(eventCount, { kind: "logs" }); this.#recordIngest("logs", "inline", "ok", startedAt); return ExportLogsServiceResponse.create(); } catch (error) { this.#recordIngest("logs", "inline", "error", startedAt); throw error; } }); } async exportTracesRaw(payload: Uint8Array): Promise { await this.#exportRawEvents("traces", payload); } async exportLogsRaw(payload: Uint8Array): Promise { await this.#exportRawEvents("logs", payload); } async exportMetricsRaw(payload: Uint8Array): Promise { await startSpan(this._tracer, "exportMetricsRaw", async (span) => { const startedAt = Date.now(); this._ingestBytes.add(payload.byteLength, { kind: "metrics" }); try { const pool = await this.#pool(); const { rows } = await pool.runTransform("metrics", payload, this.#transformConfig()); span.setAttribute("metric_row_count", rows.length); if (rows.length > 0) { await this.#exportMetricRows(rows); } this._metricRowsProduced.add(rows.length, { kind: "metrics" }); this.#recordIngest("metrics", "worker", "ok", startedAt); } catch (error) { this.#recordIngest("metrics", "worker", "error", startedAt); throw error; } }); } async #exportRawEvents(kind: "traces" | "logs", payload: Uint8Array): Promise { await startSpan( this._tracer, kind === "traces" ? "exportTracesRaw" : "exportLogsRaw", async (span) => { const startedAt = Date.now(); this._ingestBytes.add(payload.byteLength, { kind }); try { const pool = await this.#pool(); const { eventsWithStores } = await pool.runTransform( kind, payload, this.#transformConfig() ); const eventCount = await this.#exportEvents(eventsWithStores, true); span.setAttribute("event_count", eventCount); this._eventsProduced.add(eventCount, { kind }); this.#recordIngest(kind, "worker", "ok", startedAt); } catch (error) { this.#recordIngest(kind, "worker", "error", startedAt); throw error; } } ); } #transformConfig() { return { spanAttributeValueLengthLimit: this._spanAttributeValueLengthLimit, defaultEventStore: env.EVENT_REPOSITORY_DEFAULT_STORE, }; } async #pool() { await waitForLlmPricingReady(); const models = llmPricingRegistry && llmPricingRegistry.isLoaded ? llmPricingRegistry.toSerializable() : []; const pool = getOtlpWorkerPool( OTEL_TRANSFORM_WORKER_POOL_SIZE, models, env.OTEL_TRANSFORM_WORKER_PATH, this._meter ); if (!this.#pricingSubscribed) { this.#pricingSubscribed = true; // Re-broadcast pricing to workers on every registry reload so their cost math stays fresh. subscribeToPricingReload((updated) => pool.broadcastPricing(updated)); } return pool; } async #exportEvents( eventsWithStores: { events: Array; taskEventStore: string }[], alreadyEnriched = false ) { if (!alreadyEnriched) { await waitForLlmPricingReady(); } // Group by unique event repositories const routeCache = new Map(); const groups = new Map(); for (const { events, taskEventStore } of eventsWithStores) { for (const event of events) { const routeKey = `${event.organizationId}\0${taskEventStore}`; let resolved = routeCache.get(routeKey); if (!resolved) { // Non-ClickHouse stores (taskEvent / taskEventPartitioned) are Postgres-backed. // The ClickHouse factory only handles clickhouse/clickhouse_v2 and throws otherwise. if (taskEventStore !== "clickhouse" && taskEventStore !== "clickhouse_v2") { // Non-ClickHouse stores (taskEvent / taskEventPartitioned) are Postgres-backed. // The ClickHouse factory only handles clickhouse/clickhouse_v2 and throws otherwise. resolved = { key: "postgres:default", repository: eventRepository }; } else { resolved = this._clickhouseFactory.getEventRepositoryForOrganizationSync( taskEventStore, event.organizationId ); } routeCache.set(routeKey, resolved); } let group = groups.get(resolved.key); if (!group) { group = { repository: resolved.repository, events: [] }; groups.set(resolved.key, group); } group.events.push(event); } } let eventCount = 0; for (const [repoKey, { repository, events }] of groups) { const enrichedEvents = alreadyEnriched ? events : enrichCreatableEvents(events); this.#logEventsVerbose(enrichedEvents, `exportEvents ${repoKey}`); eventCount += enrichedEvents.length; repository.insertMany(enrichedEvents); } return eventCount; } async #exportMetricRows(rows: MetricsV1Input[]): Promise { const routeCache = new Map(); const groups = new Map(); for (const row of rows) { const routeKey = row.organization_id; let resolved = routeCache.get(routeKey); if (!resolved) { resolved = this._clickhouseFactory.getEventRepositoryForOrganizationSync( "clickhouse_v2", row.organization_id ); routeCache.set(routeKey, resolved); } let group = groups.get(resolved.key); if (!group) { group = { repository: resolved.repository, rows: [] }; groups.set(resolved.key, group); } group.rows.push(row); } for (const [, { repository, rows: groupedRows }] of groups) { repository.insertManyMetrics(groupedRows); } } #logEventsVerbose(events: CreateEventInput[], prefix: string) { if (!this._verbose) return; events.forEach((event) => { logger.debug(`Exporting ${prefix} event`, { event }); }); } #logExportTracesVerbose(request: ExportTraceServiceRequest) { if (!this._verbose) return; logger.debug("Exporting traces", { resourceSpans: request.resourceSpans.length, totalSpans: request.resourceSpans.reduce( (acc, resourceSpan) => acc + resourceSpan.scopeSpans.length, 0 ), }); } #logExportLogsVerbose(request: ExportLogsServiceRequest) { if (!this._verbose) return; logger.debug("Exporting logs", { resourceLogs: request.resourceLogs.length, totalLogs: request.resourceLogs.reduce( (acc, resourceLog) => acc + resourceLog.scopeLogs.reduce((acc, scopeLog) => acc + scopeLog.logRecords.length, 0), 0 ), }); } #filterResourceSpans( resourceSpans: ExportTraceServiceRequest["resourceSpans"] ): ExportTraceServiceRequest["resourceSpans"] { return resourceSpans.filter((resourceSpan) => { const triggerAttribute = resourceSpan.resource?.attributes.find( (attribute) => attribute.key === SemanticInternalAttributes.TRIGGER ); const executionEnvironmentAttribute = resourceSpan.resource?.attributes.find( (attribute) => attribute.key === SemanticInternalAttributes.EXECUTION_ENVIRONMENT ); if (!triggerAttribute && !executionEnvironmentAttribute) { logger.debug("Skipping resource span without trigger attribute", { attributes: resourceSpan.resource?.attributes, spans: resourceSpan.scopeSpans.flatMap((scopeSpan) => scopeSpan.spans), }); return true; // go ahead and let this resource span through } const executionEnvironment = isStringValue(executionEnvironmentAttribute?.value) ? executionEnvironmentAttribute.value.stringValue : undefined; if (executionEnvironment === "trigger") { return true; // go ahead and let this resource span through } return isBoolValue(triggerAttribute?.value) ? triggerAttribute.value.boolValue : false; }); } #filterResourceLogs( resourceLogs: ExportLogsServiceRequest["resourceLogs"] ): ExportLogsServiceRequest["resourceLogs"] { return resourceLogs.filter((resourceLog) => { const attribute = resourceLog.resource?.attributes.find( (attribute) => attribute.key === SemanticInternalAttributes.TRIGGER ); if (!attribute) return false; return isBoolValue(attribute.value) ? attribute.value.boolValue : false; }); } #filterResourceMetrics(resourceMetrics: ResourceMetrics[]): ResourceMetrics[] { return resourceMetrics.filter((rm) => { const triggerAttribute = rm.resource?.attributes.find( (attribute) => attribute.key === SemanticInternalAttributes.TRIGGER ); if (!triggerAttribute) return false; return isBoolValue(triggerAttribute.value) ? triggerAttribute.value.boolValue : false; }); } } export const otlpExporter = singleton("otlpExporter", initializeOTLPExporter); async function initializeOTLPExporter() { await clickhouseFactory.isReady(); return new OTLPExporter({ clickhouseFactory, verbose: process.env.OTLP_EXPORTER_VERBOSE === "1", spanAttributeValueLengthLimit: process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT ? parseInt(process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, 10) : 8192, }); }