Files
2026-07-13 13:32:57 +08:00

464 lines
16 KiB
TypeScript

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<ExportTraceServiceResponse> {
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<ExportMetricsServiceResponse> {
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<ExportLogsServiceResponse> {
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<void> {
await this.#exportRawEvents("traces", payload);
}
async exportLogsRaw(payload: Uint8Array): Promise<void> {
await this.#exportRawEvents("logs", payload);
}
async exportMetricsRaw(payload: Uint8Array): Promise<void> {
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<void> {
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<CreateEventInput>; taskEventStore: string }[],
alreadyEnriched = false
) {
if (!alreadyEnriched) {
await waitForLlmPricingReady();
}
// Group by unique event repositories
const routeCache = new Map<string, { key: string; repository: IEventRepository }>();
const groups = new Map<string, { repository: IEventRepository; events: CreateEventInput[] }>();
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<void> {
const routeCache = new Map<string, { key: string; repository: IEventRepository }>();
const groups = new Map<string, { repository: IEventRepository; rows: MetricsV1Input[] }>();
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,
});
}