{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "sink-otlp", "title": "OTLP Sink", "description": "OTLP sink that exports Event records over OpenTelemetry.", "type": "registry:lib", "dependencies": [ "@useamplio/amplio@^0.1.0-alpha.17" ], "files": [ { "path": "registry/sinks/otlp.ts", "target": "~/telemetry/sinks/otlp.ts", "type": "registry:lib", "content": "/**\n * OTLP/HTTP logs sink.\n *\n * Endpoint resolution (first match wins):\n * 1. options.endpoint — `/v1/logs` appended unless already present\n * 2. OTEL_EXPORTER_OTLP_LOGS_ENDPOINT — used VERBATIM (per the OTel spec,\n * signal-specific endpoints are full URLs)\n * 3. OTEL_EXPORTER_OTLP_ENDPOINT — base URL, `/v1/logs` appended\n *\n * Delivery: by default this sink POSTs ONE request per emit — fine for dev\n * and low traffic, not for production request rates. Pass `batch: true`\n * (100 records / 1 s) or `batch: { maxSize, maxWaitMs }` to coalesce records\n * into one export request. Batching makes the sink async-pending between\n * flushes. Amplio's `flush()` calls the sink's active drain hook, so shutdown\n * and short-lived/serverless paths can deliver a partial batch immediately.\n */\nimport type { JsonValue, Sink, SinkRecord } from \"@useamplio/amplio\";\n\nexport interface OtlpSinkOptions {\n endpoint?: string;\n headers?: Record;\n /** When false (default), export failures warn once then stay silent. Pass true to throw. */\n throwOnError?: boolean;\n /**\n * Record fields promoted to typed OTLP log attributes (the fields you can\n * filter on in an OTel backend without parsing the JSON body). Dot paths\n * walk nested objects (`\"http.status\"` → record.http.status). Replaces the\n * default list; the full record always ships in the log body.\n */\n attributes?: string[];\n /**\n * Coalesce records into one export request. `true` = 100 records / 1 s;\n * pass `{ maxSize, maxWaitMs }` to tune. Default: off (one POST per emit).\n */\n batch?: boolean | { maxSize?: number; maxWaitMs?: number };\n}\n\nconst DEFAULT_ATTRIBUTE_FIELDS = [\n \"service\",\n \"@event\",\n \"duration_ms\",\n \"request_id\",\n \"success\",\n // The fields people actually filter on in an OTel backend:\n \"http.method\",\n \"http.route\",\n \"http.status\",\n \"rpc.procedures\",\n] as const;\n\nconst DEFAULT_BATCH_MAX_SIZE = 100;\nconst DEFAULT_BATCH_MAX_WAIT_MS = 1_000;\n\ntype OtlpAttributeValue =\n | { stringValue: string }\n | { boolValue: boolean }\n | { intValue: string }\n | { doubleValue: number }\n | { arrayValue: { values: OtlpAttributeValue[] } }\n | { kvlistValue: { values: OtlpAttribute[] } };\n\ninterface OtlpAttribute {\n key: string;\n value: OtlpAttributeValue;\n}\n\nconst parseOtlpHeaders = (raw: string | undefined): Record => {\n if (!raw) {\n return {};\n }\n\n const headers: Record = {};\n for (const pair of raw.split(\",\")) {\n const trimmed = pair.trim();\n if (!trimmed) {\n continue;\n }\n\n const eq = trimmed.indexOf(\"=\");\n if (eq === -1) {\n continue;\n }\n\n const key = trimmed.slice(0, eq).trim();\n const value = trimmed.slice(eq + 1).trim();\n if (key) {\n headers[key] = value;\n }\n }\n\n return headers;\n};\n\n/** Flat key first (`record[\"http.status\"]`), then dot-path walk (`record.http.status`). */\nconst fieldValue = (\n record: SinkRecord,\n field: string,\n): JsonValue | undefined => {\n if (field === \"@event\") {\n return record[\"@event\"];\n }\n const flat = record[field];\n if (flat !== undefined || !field.includes(\".\")) {\n return flat;\n }\n let current: JsonValue | undefined = record as JsonValue;\n for (const part of field.split(\".\")) {\n if (\n current === null ||\n typeof current !== \"object\" ||\n Array.isArray(current)\n ) {\n return undefined;\n }\n current = (current as Record)[part];\n }\n return current;\n};\n\nconst toOtlpValue = (\n value: JsonValue | undefined,\n): OtlpAttributeValue | undefined => {\n if (value === null || value === undefined) {\n return undefined;\n }\n\n if (typeof value === \"string\") {\n return { stringValue: value };\n }\n\n if (typeof value === \"boolean\") {\n return { boolValue: value };\n }\n\n if (typeof value === \"number\") {\n if (Number.isInteger(value)) {\n return { intValue: String(value) };\n }\n return { doubleValue: value };\n }\n\n if (Array.isArray(value)) {\n const values = value\n .map((item) => toOtlpValue(item))\n .filter((item): item is OtlpAttributeValue => item !== undefined);\n return { arrayValue: { values } };\n }\n\n const values = Object.entries(value)\n .map(([key, item]) => toOtlpAttribute(key, item))\n .filter((item): item is OtlpAttribute => item !== undefined);\n return { kvlistValue: { values } };\n};\n\nconst toOtlpAttribute = (\n key: string,\n value: JsonValue | undefined,\n): OtlpAttribute | undefined => {\n const converted = toOtlpValue(value);\n return converted ? { key, value: converted } : undefined;\n};\n\nconst toTimeUnixNano = (timestamp: JsonValue | undefined): string => {\n let ms: number;\n\n if (typeof timestamp === \"number\" && Number.isFinite(timestamp)) {\n ms = timestamp;\n } else if (typeof timestamp === \"string\" && timestamp.length > 0) {\n const parsed = Date.parse(timestamp);\n ms = Number.isNaN(parsed) ? Date.now() : parsed;\n } else {\n ms = Date.now();\n }\n\n return `${Math.trunc(ms)}000000`;\n};\n\nconst resolveUrl = (options: OtlpSinkOptions): string | undefined => {\n const appendLogsPath = (endpoint: string): string => {\n const base = endpoint.replace(/\\/$/, \"\");\n return base.endsWith(\"/v1/logs\") ? base : `${base}/v1/logs`;\n };\n\n if (options.endpoint) {\n return appendLogsPath(options.endpoint);\n }\n // Per the OTel spec the signal-specific endpoint is a full URL used as-is;\n // only the base endpoint gets the signal path appended.\n const signalEndpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;\n if (signalEndpoint) {\n return signalEndpoint;\n }\n const baseEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;\n if (baseEndpoint) {\n return appendLogsPath(baseEndpoint);\n }\n return undefined;\n};\n\nconst resourceKeyOf = (record: SinkRecord): string => {\n const attributes = buildResourceAttributes(record).sort((left, right) =>\n left.key.localeCompare(right.key),\n );\n return JSON.stringify(attributes);\n};\n\nconst buildResourceAttributes = (record: SinkRecord): OtlpAttribute[] => {\n const attributes: OtlpAttribute[] = [];\n const keys = new Set();\n const add = (attribute: OtlpAttribute | undefined): void => {\n if (!attribute || keys.has(attribute.key)) return;\n keys.add(attribute.key);\n attributes.push(attribute);\n };\n\n if (typeof record.service === \"string\" && record.service.length > 0) {\n add({ key: \"service.name\", value: { stringValue: record.service } });\n }\n if (typeof record.env === \"string\" && record.env.length > 0) {\n add({\n key: \"deployment.environment\",\n value: { stringValue: record.env },\n });\n }\n\n const resource = record.resource;\n if (\n resource !== null &&\n typeof resource === \"object\" &&\n !Array.isArray(resource)\n ) {\n for (const [key, value] of Object.entries(resource)) {\n add(toOtlpAttribute(key, value));\n }\n }\n\n return attributes;\n};\n\nexport function otlpSink(options: OtlpSinkOptions = {}): Sink {\n const url = resolveUrl(options);\n const throwOnError = options.throwOnError ?? false;\n const attributeFields = options.attributes ?? DEFAULT_ATTRIBUTE_FIELDS;\n const headers = {\n \"content-type\": \"application/json\",\n ...parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS),\n ...(options.headers ?? {}),\n };\n\n if (!url) {\n let warned = false;\n return async () => {\n if (!warned) {\n warned = true;\n console.warn(\n \"[amplio] otlpSink: no endpoint configured — set OTEL_EXPORTER_OTLP_LOGS_ENDPOINT (full URL, used verbatim) or OTEL_EXPORTER_OTLP_ENDPOINT (base URL, /v1/logs appended), or pass otlpSink({ endpoint }); OTLP export is disabled\",\n );\n }\n };\n }\n\n let warnedExportFailure = false;\n\n const warnExportFailure = (detail: string): void => {\n if (throwOnError || warnedExportFailure) {\n return;\n }\n warnedExportFailure = true;\n console.warn(\n `[amplio] otlpSink: export failed (${detail}); further failures will be silent. Pass throwOnError: true to fail hard.`,\n );\n };\n\n const buildAttributes = (record: SinkRecord): OtlpAttribute[] => {\n const attributes: OtlpAttribute[] = [];\n for (const field of attributeFields) {\n // Keep the conventional OTLP attribute key `event` while sourcing the\n // canonical semantic `@event` field by default.\n const key = field === \"@event\" ? \"event\" : field;\n const attr = toOtlpAttribute(key, fieldValue(record, field));\n if (attr) {\n attributes.push(attr);\n }\n }\n return attributes;\n };\n\n const toLogRecord = (record: SinkRecord) => {\n const attributes = buildAttributes(record);\n return {\n timeUnixNano: toTimeUnixNano(record.timestamp),\n body: { stringValue: JSON.stringify(record) },\n ...(attributes.length > 0 ? { attributes } : {}),\n };\n };\n\n // One resourceLogs entry per distinct OTLP resource — records in a batch\n // usually share one, but never stamp record A with B's resource attributes.\n const buildPayload = (records: SinkRecord[]) => {\n const groups = new Map();\n for (const record of records) {\n const key = resourceKeyOf(record);\n const group = groups.get(key);\n if (group) {\n group.push(record);\n } else {\n groups.set(key, [record]);\n }\n }\n\n const resourceLogs = [...groups.values()].map((group) => {\n const first = group[0]!;\n const resourceAttributes = buildResourceAttributes(first);\n return {\n ...(resourceAttributes.length > 0\n ? { resource: { attributes: resourceAttributes } }\n : {}),\n scopeLogs: [{ logRecords: group.map(toLogRecord) }],\n };\n });\n\n return { resourceLogs };\n };\n\n const exportRecords = async (records: SinkRecord[]): Promise => {\n let response: Response;\n try {\n response = await fetch(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify(buildPayload(records)),\n });\n } catch (error) {\n if (throwOnError) {\n throw error;\n }\n warnExportFailure(\"network_error\");\n return;\n }\n\n if (!response.ok) {\n if (throwOnError) {\n throw new Error(`OTLP export failed with status ${response.status}`);\n }\n warnExportFailure(`status ${response.status}`);\n return;\n }\n };\n\n if (!options.batch) {\n return (record: SinkRecord) => exportRecords([record]);\n }\n\n const batchConfig = options.batch === true ? {} : options.batch;\n const maxSize = batchConfig.maxSize ?? DEFAULT_BATCH_MAX_SIZE;\n const maxWaitMs = batchConfig.maxWaitMs ?? DEFAULT_BATCH_MAX_WAIT_MS;\n\n let buffer: SinkRecord[] = [];\n let timer: ReturnType | null = null;\n let pending: {\n promise: Promise;\n resolve: () => void;\n reject: (e: unknown) => void;\n } | null = null;\n\n const flushBatch = (): Promise => {\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n const records = buffer;\n const settled = pending;\n buffer = [];\n pending = null;\n if (records.length === 0) {\n settled?.resolve();\n return Promise.resolve();\n }\n const delivery = exportRecords(records).then(\n () => settled?.resolve(),\n (error) => {\n // Every emit in the batch awaited this promise — reject them all so\n // throwOnError surfaces per-record, matching the unbatched contract.\n if (settled) {\n settled.reject(error);\n }\n },\n );\n return delivery;\n };\n\n const sink: Sink = (record: SinkRecord): Promise => {\n buffer.push(record);\n if (pending === null) {\n let resolve!: () => void;\n let reject!: (e: unknown) => void;\n const promise = new Promise((res, rej) => {\n resolve = res;\n reject = rej;\n });\n pending = { promise, resolve, reject };\n }\n const result = pending.promise;\n if (buffer.length >= maxSize) {\n void flushBatch();\n } else if (timer === null) {\n timer = setTimeout(flushBatch, maxWaitMs);\n // Don't keep a Node process alive just for a pending batch window.\n (timer as { unref?: () => void }).unref?.();\n }\n return result;\n };\n sink.flush = flushBatch;\n return sink;\n}\n" } ] }