diff --git a/.changeset/lucky-cameras-shave.md b/.changeset/lucky-cameras-shave.md
new file mode 100644
index 0000000000..867f22309e
--- /dev/null
+++ b/.changeset/lucky-cameras-shave.md
@@ -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
diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
index 04af907358..4239278cee 100644
--- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
@@ -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,
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
index 613720ef08..15514ecd59 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
@@ -1071,6 +1071,17 @@ function SpanEntity({ span }: { span: Span }) {
showOpenInModal
/>
) : null}
+ {span.resourceProperties !== undefined ? (
+
+ ) : null}
);
}
diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
index b87b8001f2..0ade9436d4 100644
--- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
+++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
@@ -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) {
+ return variables.map((variable) => {
+ return {
+ ...variable,
+ key: renameMap[variable.key] ?? variable.key,
+ };
+ });
+}
+
async function resolveOverridableTriggerVariables(
runtimeEnvironment: RuntimeEnvironmentForEnvRepo
) {
diff --git a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts
index 15bd85f9eb..8bf841c0bc 100644
--- a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts
+++ b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts
@@ -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 {
+ private createEventToTaskEventV1InputAttributes(
+ attributes: Attributes,
+ resourceAttributes?: Attributes
+ ): Record {
+ if (!attributes && !resourceAttributes) {
+ return {};
+ }
+
+ return {
+ ...this.createAttributesToInputAttributes(attributes),
+ ...this.createAttributesToInputAttributes(resourceAttributes, "$resource"),
+ };
+ }
+
+ private createAttributesToInputAttributes(
+ attributes: Attributes | undefined,
+ key?: string
+ ): Record {
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 | undefined;
}
}
diff --git a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts
index 2d484480ab..dcbdb07a3d 100644
--- a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts
+++ b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts
@@ -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 | number | boolean | null | undefined; // Displayed as JSON in span properties (CodeBlock)
+ resourceProperties?: Record | string | number | boolean | null | undefined; // Displayed as JSON in span resource properties (CodeBlock)
// ============================================================================
// Entity & Relationships
diff --git a/apps/webapp/app/v3/otlpExporter.server.ts b/apps/webapp/app/v3/otlpExporter.server.ts
index cb05b375fc..8c60e72ac4 100644
--- a/apps/webapp/app/v3/otlpExporter.server.ts
+++ b/apps/webapp/app/v3/otlpExporter.server.ts
@@ -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),
[]
diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts
index ac2a4afbb1..98214d7a00 100644
--- a/packages/cli-v3/src/dev/devSupervisor.ts
+++ b/packages/cli-v3/src/dev/devSupervisor.ts
@@ -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,
};
}
diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts
index bed0fbaf96..7cd88ab5a9 100644
--- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts
+++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts
@@ -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);
diff --git a/packages/cli-v3/src/entryPoints/managed-run-worker.ts b/packages/cli-v3/src/entryPoints/managed-run-worker.ts
index 14e3d24a1c..f1512f27f0 100644
--- a/packages/cli-v3/src/entryPoints/managed-run-worker.ts
+++ b/packages/cli-v3/src/entryPoints/managed-run-worker.ts
@@ -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);
diff --git a/packages/cli-v3/src/utilities/dotEnv.ts b/packages/cli-v3/src/utilities/dotEnv.ts
index f50b26f501..83cb92822a 100644
--- a/packages/cli-v3/src/utilities/dotEnv.ts
+++ b/packages/cli-v3/src/utilities/dotEnv.ts
@@ -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;
}
diff --git a/packages/core/src/v3/config.ts b/packages/core/src/v3/config.ts
index 71c4cd6521..9c6871a264 100644
--- a/packages/core/src/v3/config.ts
+++ b/packages/core/src/v3/config.ts
@@ -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;
+
+ /**
+ * 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;
};
/**
diff --git a/packages/core/src/v3/otel/tracingSDK.ts b/packages/core/src/v3/otel/tracingSDK.ts
index 7fcb82450c..9bfd098ffd 100644
--- a/packages/core/src/v3/otel/tracingSDK.ts
+++ b/packages/core/src/v3/otel/tracingSDK.ts
@@ -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 {
+ if (!rawEnvAttributes) return {};
+
+ const COMMA = ",";
+ const KV = "=";
+ const attributes: Record = {};
+
+ // 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;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d512a2127..b61b567b00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2593,6 +2593,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':
@@ -9909,6 +9928,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}
@@ -10660,6 +10689,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}
diff --git a/references/telemetry/package.json b/references/telemetry/package.json
new file mode 100644
index 0000000000..cb9bff620f
--- /dev/null
+++ b/references/telemetry/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/references/telemetry/src/trigger/tasks.ts b/references/telemetry/src/trigger/tasks.ts
new file mode 100644
index 0000000000..d1a85ec166
--- /dev/null
+++ b/references/telemetry/src/trigger/tasks.ts
@@ -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!" };
+ },
+});
diff --git a/references/telemetry/trigger.config.ts b/references/telemetry/trigger.config.ts
new file mode 100644
index 0000000000..48e6a877ac
--- /dev/null
+++ b/references/telemetry/trigger.config.ts
@@ -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",
+ }),
+ },
+});
diff --git a/references/telemetry/tsconfig.json b/references/telemetry/tsconfig.json
new file mode 100644
index 0000000000..9a5ee0b9d6
--- /dev/null
+++ b/references/telemetry/tsconfig.json
@@ -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"]
+}