feat(otel): support for custom resource attributes via config#telemetry.resource and OTEL_RESOURCE_ATTRIBUTES env var (#2704)

This commit is contained in:
Eric Allam
2025-11-24 14:33:07 +00:00
committed by GitHub
parent f7240a99e7
commit 72e286af2f
18 changed files with 328 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
---
feat: add ability to set custom resource properties through trigger.config.ts or via the OTEL_RESOURCE_ATTRIBUTES env var
@@ -497,7 +497,18 @@ export class SpanPresenter extends BasePresenter {
duration: span.duration,
events: span.events,
style: span.style,
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
properties:
span.properties &&
typeof span.properties === "object" &&
Object.keys(span.properties).length > 0
? JSON.stringify(span.properties, null, 2)
: undefined,
resourceProperties:
span.resourceProperties &&
typeof span.resourceProperties === "object" &&
Object.keys(span.resourceProperties).length > 0
? JSON.stringify(span.resourceProperties, null, 2)
: undefined,
entity: span.entity,
metadata: span.metadata,
triggeredRuns,
@@ -1071,6 +1071,17 @@ function SpanEntity({ span }: { span: Span }) {
showOpenInModal
/>
) : null}
{span.resourceProperties !== undefined ? (
<CodeBlock
rowTitle="Resource properties"
code={span.resourceProperties}
maxLines={20}
showLineNumbers={false}
showCopyButton
showTextWrapping
showOpenInModal
/>
) : null}
</div>
);
}
@@ -825,12 +825,16 @@ export async function resolveVariablesForEnvironment(
runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
parentEnvironment?: RuntimeEnvironmentForEnvRepo
) {
const projectSecrets = await environmentVariablesRepository.getEnvironmentVariables(
let projectSecrets = await environmentVariablesRepository.getEnvironmentVariables(
runtimeEnvironment.projectId,
runtimeEnvironment.id,
parentEnvironment?.id
);
projectSecrets = renameVariables(projectSecrets, {
OTEL_RESOURCE_ATTRIBUTES: "CUSTOM_OTEL_RESOURCE_ATTRIBUTES",
});
const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment);
const builtInVariables =
@@ -853,6 +857,15 @@ export async function resolveVariablesForEnvironment(
return result;
}
function renameVariables(variables: EnvironmentVariable[], renameMap: Record<string, string>) {
return variables.map((variable) => {
return {
...variable,
key: renameMap[variable.key] ?? variable.key,
};
});
}
async function resolveOverridableTriggerVariables(
runtimeEnvironment: RuntimeEnvironmentForEnvRepo
) {
@@ -184,7 +184,10 @@ export class ClickhouseEventRepository implements IEventRepository {
message: event.message,
kind: this.createEventToTaskEventV1InputKind(event),
status: this.createEventToTaskEventV1InputStatus(event),
attributes: this.createEventToTaskEventV1InputAttributes(event.properties),
attributes: this.createEventToTaskEventV1InputAttributes(
event.properties,
event.resourceProperties
),
metadata: this.createEventToTaskEventV1InputMetadata(event),
expires_at: convertDateToClickhouseDateTime(
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
@@ -392,7 +395,24 @@ export class ClickhouseEventRepository implements IEventRepository {
return "OK";
}
private createEventToTaskEventV1InputAttributes(attributes: Attributes): Record<string, unknown> {
private createEventToTaskEventV1InputAttributes(
attributes: Attributes,
resourceAttributes?: Attributes
): Record<string, unknown> {
if (!attributes && !resourceAttributes) {
return {};
}
return {
...this.createAttributesToInputAttributes(attributes),
...this.createAttributesToInputAttributes(resourceAttributes, "$resource"),
};
}
private createAttributesToInputAttributes(
attributes: Attributes | undefined,
key?: string
): Record<string, unknown> {
if (!attributes) {
return {};
}
@@ -406,6 +426,12 @@ export class ClickhouseEventRepository implements IEventRepository {
const unflattenedAttributes = unflattenAttributes(publicAttributes);
if (unflattenedAttributes && typeof unflattenedAttributes === "object") {
if (key) {
return {
[key]: unflattenedAttributes,
};
}
return {
...unflattenedAttributes,
};
@@ -1103,6 +1129,7 @@ export class ClickhouseEventRepository implements IEventRepository {
events: [],
style: {},
properties: undefined,
resourceProperties: undefined,
entity: {
type: undefined,
id: undefined,
@@ -1177,8 +1204,19 @@ export class ClickhouseEventRepository implements IEventRepository {
}
}
if (!span.properties && typeof record.attributes_text === "string") {
span.properties = this.#parseAttributes(record.attributes_text);
if (
(span.properties == null ||
(typeof span.properties === "object" && Object.keys(span.properties).length === 0)) &&
typeof record.attributes_text === "string"
) {
const parsedAttributes = this.#parseAttributes(record.attributes_text);
const resourceAttributes = parsedAttributes["$resource"];
// Remove the $resource key from the attributes
delete parsedAttributes["$resource"];
span.properties = parsedAttributes;
span.resourceProperties = resourceAttributes as Record<string, unknown> | undefined;
}
}
@@ -53,6 +53,7 @@ export type CreateEventInput = Omit<
| "links"
> & {
properties: Attributes;
resourceProperties?: Attributes;
metadata: Attributes | undefined;
style: Attributes | undefined;
};
@@ -209,6 +210,7 @@ export type SpanDetail = {
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
+38
View File
@@ -204,6 +204,24 @@ function convertLogsToCreateableEvents(
const resourceProperties = extractEventProperties(resourceAttributes);
const userDefinedResourceAttributes = truncateAttributes(
convertKeyValueItemsToMap(resourceAttributes ?? [], [], undefined, [
SemanticInternalAttributes.USAGE,
SemanticInternalAttributes.SPAN,
SemanticInternalAttributes.METADATA,
SemanticInternalAttributes.STYLE,
SemanticInternalAttributes.METRIC_EVENTS,
SemanticInternalAttributes.TRIGGER,
"process",
"sdk",
"service",
"ctx",
"cli",
"cloud",
]),
spanAttributeValueLengthLimit
);
const taskEventStore =
extractStringAttribute(resourceAttributes, [SemanticInternalAttributes.TASK_EVENT_STORE]) ??
env.EVENT_REPOSITORY_DEFAULT_STORE;
@@ -249,6 +267,7 @@ function convertLogsToCreateableEvents(
status: logLevelToEventStatus(log.severityNumber),
startTime: log.timeUnixNano,
properties,
resourceProperties: userDefinedResourceAttributes,
style: convertKeyValueItemsToMap(
pickAttributes(log.attributes ?? [], SemanticInternalAttributes.STYLE),
[]
@@ -285,6 +304,24 @@ function convertSpansToCreateableEvents(
const resourceProperties = extractEventProperties(resourceAttributes);
const userDefinedResourceAttributes = truncateAttributes(
convertKeyValueItemsToMap(resourceAttributes ?? [], [], undefined, [
SemanticInternalAttributes.USAGE,
SemanticInternalAttributes.SPAN,
SemanticInternalAttributes.METADATA,
SemanticInternalAttributes.STYLE,
SemanticInternalAttributes.METRIC_EVENTS,
SemanticInternalAttributes.TRIGGER,
"process",
"sdk",
"service",
"ctx",
"cli",
"cloud",
]),
spanAttributeValueLengthLimit
);
const taskEventStore =
extractStringAttribute(resourceAttributes, [SemanticInternalAttributes.TASK_EVENT_STORE]) ??
env.EVENT_REPOSITORY_DEFAULT_STORE;
@@ -336,6 +373,7 @@ function convertSpansToCreateableEvents(
events: spanEventsToEventEvents(span.events ?? []),
duration: span.endTimeUnixNano - span.startTimeUnixNano,
properties,
resourceProperties: userDefinedResourceAttributes,
style: convertKeyValueItemsToMap(
pickAttributes(span.attributes ?? [], SemanticInternalAttributes.STYLE),
[]
-3
View File
@@ -455,9 +455,6 @@ class DevSupervisor implements WorkerRuntime {
TRIGGER_API_URL: this.options.client.apiURL,
TRIGGER_SECRET_KEY: this.options.client.accessToken!,
OTEL_EXPORTER_OTLP_COMPRESSION: "none",
OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({
[SemanticInternalAttributes.PROJECT_DIR]: this.options.config.workingDir,
}),
OTEL_IMPORT_HOOK_INCLUDES,
};
}
@@ -209,6 +209,7 @@ async function doBootstrap() {
logExporters: config.telemetry?.logExporters ?? [],
diagLogLevel: (env.TRIGGER_OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none",
forceFlushTimeoutMillis: 30_000,
resource: config.telemetry?.resource,
});
const otelTracer: Tracer = tracingSDK.getTracer("trigger-dev-worker", VERSION);
@@ -188,6 +188,7 @@ async function doBootstrap() {
forceFlushTimeoutMillis: 30_000,
exporters: config.telemetry?.exporters ?? [],
logExporters: config.telemetry?.logExporters ?? [],
resource: config.telemetry?.resource,
});
const otelTracer: Tracer = tracingSDK.getTracer("trigger-dev-worker", VERSION);
+12 -1
View File
@@ -2,7 +2,13 @@ import dotenv from "dotenv";
import { resolve } from "node:path";
import { env } from "std-env";
const ENVVAR_FILES = [".env", ".env.development", ".env.local", ".env.development.local", "dev.vars"];
const ENVVAR_FILES = [
".env",
".env.development",
".env.local",
".env.development.local",
"dev.vars",
];
export function resolveDotEnvVars(cwd?: string, envFile?: string) {
const result: { [key: string]: string } = {};
@@ -23,6 +29,11 @@ export function resolveDotEnvVars(cwd?: string, envFile?: string) {
delete result.TRIGGER_SECRET_KEY;
delete result.OTEL_EXPORTER_OTLP_ENDPOINT;
if (result.OTEL_RESOURCE_ATTRIBUTES) {
result.CUSTOM_OTEL_RESOURCE_ATTRIBUTES = result.OTEL_RESOURCE_ATTRIBUTES;
delete result.OTEL_RESOURCE_ATTRIBUTES;
}
return result;
}
+8
View File
@@ -12,6 +12,7 @@ import type {
import type { LogLevel } from "./logger/taskLogger.js";
import type { MachinePresetName } from "./schemas/common.js";
import { LogRecordExporter } from "@opentelemetry/sdk-logs";
import type { Resource } from "@opentelemetry/resources";
export type CompatibilityFlag = "run_engine_v2";
@@ -107,6 +108,13 @@ export type TriggerConfig = {
* @see https://trigger.dev/docs/config/config-file#exporters
*/
logExporters?: Array<LogRecordExporter>;
/**
* Resource to use for OpenTelemetry. This is useful if you want to add custom resources to your tasks.
*
* @see https://trigger.dev/docs/config/config-file#resource
*/
resource?: Resource;
};
/**
+85 -2
View File
@@ -10,7 +10,12 @@ import { TraceState } from "@opentelemetry/core";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations, type Instrumentation } from "@opentelemetry/instrumentation";
import { detectResources, processDetector, resourceFromAttributes } from "@opentelemetry/resources";
import {
detectResources,
processDetector,
Resource,
resourceFromAttributes,
} from "@opentelemetry/resources";
import {
BatchLogRecordProcessor,
LogRecordExporter,
@@ -64,6 +69,7 @@ export type TracingSDKConfig = {
exporters?: SpanExporter[];
logExporters?: LogRecordExporter[];
diagLogLevel?: TracingDiagnosticLogLevel;
resource?: Resource;
};
const idGenerator = new RandomIdGenerator();
@@ -84,6 +90,10 @@ export class TracingSDK {
? JSON.parse(envResourceAttributesSerialized)
: {};
const customEnvResourceAttributes = parseOtelResourceAttributes(
getEnvVar("CUSTOM_OTEL_RESOURCE_ATTRIBUTES")
);
const commonResources = detectResources({
detectors: [processDetector],
})
@@ -99,7 +109,9 @@ export class TracingSDK {
})
)
.merge(resourceFromAttributes(envResourceAttributes))
.merge(resourceFromAttributes(taskContext.resourceAttributes));
.merge(resourceFromAttributes(customEnvResourceAttributes))
.merge(resourceFromAttributes(taskContext.resourceAttributes))
.merge(config.resource ?? resourceFromAttributes({}));
const spanExporter = new OTLPTraceExporter({
url: `${config.url}/v1/traces`,
@@ -489,3 +501,74 @@ function isTraceFlagSampled(traceFlags?: number): boolean {
return (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED;
}
function isPrintableAscii(str: string): boolean {
// printable ASCII: 0x20 (space) .. 0x7E (~)
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 0x20 || code > 0x7e) return false;
}
return true;
}
function isValid(name: string | undefined): boolean {
if (!name) return false;
return typeof name === "string" && name.length <= 255 && isPrintableAscii(name);
}
function isValidAndNotEmpty(name: string | undefined): boolean {
if (!name) return false;
return isValid(name) && name.length > 0;
}
export function parseOtelResourceAttributes(
rawEnvAttributes: string | undefined | null
): Record<string, string> {
if (!rawEnvAttributes) return {};
const COMMA = ",";
const KV = "=";
const attributes: Record<string, string> = {};
// use negative limit to support trailing empty attribute
const rawAttributes = rawEnvAttributes.split(COMMA, -1);
for (const rawAttribute of rawAttributes) {
const keyValuePair = rawAttribute.split(KV, -1);
if (keyValuePair.length !== 2) {
// skip invalid pair
continue;
}
let [key, value] = keyValuePair;
key = key?.trim();
// trim and remove surrounding double quotes
value = value?.trim().replace(/^"|"$/g, "");
if (!value || !key) {
continue;
}
if (!isValidAndNotEmpty(key)) {
throw new Error(
`Attribute key should be a ASCII string with a length greater than 0 and not exceed 255 characters.`
);
}
if (!isValid(value)) {
throw new Error(
`Attribute value should be a ASCII string with a length not exceed 255 characters.`
);
}
// decode percent-encoding (deployment%20name -> deployment name)
try {
attributes[key] = decodeURIComponent(value);
} catch (e: unknown) {
// decodeURIComponent can throw for malformed sequences; rethrow or handle
if (e instanceof Error) {
throw new Error(`Failed to decode attribute value for key ${key}: ${e.message}`);
}
throw new Error(`Failed to decode attribute value for key ${key}`);
}
}
return attributes;
}
+40
View File
@@ -2636,6 +2636,25 @@ importers:
specifier: ^5
version: 5.5.4
references/telemetry:
dependencies:
'@opentelemetry/resources':
specifier: 2.2.0
version: 2.2.0(@opentelemetry/api@1.9.0)
'@trigger.dev/sdk':
specifier: workspace:*
version: link:../../packages/trigger-sdk
devDependencies:
'@types/node':
specifier: ^20
version: 20.14.14
trigger.dev:
specifier: workspace:*
version: link:../../packages/cli-v3
typescript:
specifier: ^5
version: 5.5.4
references/test-tasks:
dependencies:
'@trigger.dev/sdk':
@@ -9952,6 +9971,16 @@ packages:
'@opentelemetry/semantic-conventions': 1.36.0
dev: false
/@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0):
resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.36.0
dev: false
/@opentelemetry/exporter-logs-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0):
resolution: {integrity: sha512-g/2Y2noc/l96zmM+g0LdeuyYKINyBwN6FJySoU15LHPLcMN/1a0wNk2SegwKcxrRdE7Xsm7fkIR5n6XFe3QpPw==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -10703,6 +10732,17 @@ packages:
'@opentelemetry/semantic-conventions': 1.36.0
dev: false
/@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0):
resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.36.0
dev: false
/@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0):
resolution: {integrity: sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==}
engines: {node: ^18.19.0 || >=20.6.0}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "references-telemetry",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "trigger dev",
"deploy": "trigger deploy"
},
"dependencies": {
"@trigger.dev/sdk": "workspace:*",
"@opentelemetry/resources": "2.2.0"
},
"devDependencies": {
"@types/node": "^20",
"trigger.dev": "workspace:*",
"typescript": "^5"
}
}
@@ -0,0 +1,9 @@
import { logger, task } from "@trigger.dev/sdk";
export const telemetryTestTask = task({
id: "telemetry-test",
run: async (payload: any, { ctx }) => {
logger.info("Hello, world!", { payload, ctx });
return { message: "Hello, world!" };
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "@trigger.dev/sdk";
import { resourceFromAttributes } from "@opentelemetry/resources";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
dirs: ["./src/trigger"],
maxDuration: 3600,
telemetry: {
resource: resourceFromAttributes({
"foo.bar": "telemetry-test",
"foo.baz": "1.0.0",
}),
},
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"customConditions": ["@triggerdotdev/source"],
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["./src/**/*.ts", "trigger.config.ts"]
}