authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-24 15:14:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 12:14:28-07:00
log87d84280192195b338e27acf2333771a13d04e61
tree008a412c649bfef81f313b2df209752ea4eaad83
parent3412097bff7ffa62ef79e735c65d1e5cff71c356
signature Signed by PGP key B5690EEEBB952194

fix(observability): a malformed OTEL_RESOURCE_ATTRIBUTES no longer stops startup (#13469)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

4 files changed, 120 insertions(+), 5 deletions(-)

apps/desktop/src/app/DesktopObservability.ts+12-2
...@@ -356,7 +356,13 @@ const readPersistedObservabilitySettings: Effect.Effect<...@@ -356,7 +356,13 @@ const readPersistedObservabilitySettings: Effect.Effect<
356const resolveOtlpEndpoints = Effect.gen(function* () {356const resolveOtlpEndpoints = Effect.gen(function* () {
357 const otel = yield* OtelEnvironment.load;357 const otel = yield* OtelEnvironment.load;
358 if (otel.disabled) {358 if (otel.disabled) {
359 return { traces: undefined, metrics: undefined, logs: undefined, warnings: otel.warnings };359 return {
360 traces: undefined,
361 metrics: undefined,
362 logs: undefined,
363 warnings: otel.warnings,
364 resourceAttributes: otel.resourceAttributes,
365 };
360 }366 }
361367
362 const environment = yield* DesktopEnvironment.DesktopEnvironment;368 const environment = yield* DesktopEnvironment.DesktopEnvironment;
...@@ -366,6 +372,7 @@ const resolveOtlpEndpoints = Effect.gen(function* () {...@@ -366,6 +372,7 @@ const resolveOtlpEndpoints = Effect.gen(function* () {
366 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,372 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
367 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,373 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
368 warnings: otel.warnings,374 warnings: otel.warnings,
375 resourceAttributes: otel.resourceAttributes,
369 };376 };
370});377});
371378
...@@ -683,7 +690,10 @@ const telemetryLayer = Layer.unwrap(...@@ -683,7 +690,10 @@ const telemetryLayer = Layer.unwrap(
683 Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)),690 Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)),
684 );691 );
685692
686 return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)));693 return otelWarningsLayer.pipe(
694 Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)),
695 Layer.provide(OtelEnvironment.layerResourceAttributes(endpoints.resourceAttributes)),
696 );
687 }),697 }),
688);698);
689699
apps/server/src/observability/Layers/Observability.ts+4
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 makeTraceSink,4 makeTraceSink,
5 otlpSerializationLayer,5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";6} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
9import * as References from "effect/References";10import * as References from "effect/References";
...@@ -96,6 +97,9 @@ export const ObservabilityLive = Layer.unwrap(...@@ -96,6 +97,9 @@ export const ObservabilityLive = Layer.unwrap(
96 Layer.provideMerge(97 Layer.provideMerge(
97 Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer),98 Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer),
98 ),99 ),
100 Layer.provide(
101 OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes),
102 ),
99 );103 );
100 }),104 }),
101);105);
packages/shared/src/otelEnvironment.test.ts+49
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as OtlpResource from "effect/unstable/observability/OtlpResource";
46
5import * as OtelEnvironment from "./otelEnvironment.ts";7import * as OtelEnvironment from "./otelEnvironment.ts";
68
...@@ -79,4 +81,51 @@ describe("OtelEnvironment", () => {...@@ -79,4 +81,51 @@ describe("OtelEnvironment", () => {
79 assert.deepStrictEqual(resolved.warnings, warnings);81 assert.deepStrictEqual(resolved.warnings, warnings);
80 }),82 }),
81 );83 );
84
85 it.effect.each([
86 { name: "unset", env: {}, resourceAttributes: {}, warnings: [] },
87 {
88 name: "a percent-encoded list",
89 env: { OTEL_RESOURCE_ATTRIBUTES: "team=core,message=hello%20world" },
90 resourceAttributes: { team: "core", message: "hello world" },
91 warnings: [],
92 },
93 {
94 name: "a list that does not decode",
95 env: { OTEL_RESOURCE_ATTRIBUTES: "team=core,broken=%zz" },
96 resourceAttributes: {},
97 warnings: [
98 "OTEL_RESOURCE_ATTRIBUTES is not a list of percent-encoded key=value pairs and was ignored",
99 ],
100 },
101 ])("resource attributes: $name", ({ env, resourceAttributes, warnings }) =>
102 Effect.gen(function* () {
103 const resolved = yield* load(env);
104 assert.deepStrictEqual(resolved.resourceAttributes, resourceAttributes);
105 assert.deepStrictEqual(resolved.warnings, warnings);
106 }),
107 );
108
109 describe("layerResourceAttributes", () => {
110 it.effect.each([
111 { name: "a list that does not decode", raw: "team=%zz", attributes: [] },
112 { name: "encoded separators", raw: "a%2Cb=x%3Dy", attributes: ["a,b"] },
113 ])("lets the exporters' own read succeed with $name", ({ raw, attributes }) =>
114 Effect.gen(function* () {
115 const env = ConfigProvider.layer(
116 ConfigProvider.fromEnv({ env: { OTEL_RESOURCE_ATTRIBUTES: raw } }),
117 );
118 const otel = yield* OtelEnvironment.load.pipe(Effect.provide(env));
119 const resource = yield* OtlpResource.fromConfig({ serviceName: "t3" }).pipe(
120 Effect.provide(
121 Layer.provide(OtelEnvironment.layerResourceAttributes(otel.resourceAttributes), env),
122 ),
123 );
124 assert.deepStrictEqual(
125 resource.attributes.map((attribute) => attribute.key),
126 [...attributes, "service.name"],
127 );
128 }),
129 );
130 });
82});131});
packages/shared/src/otelEnvironment.ts+55-3
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 * @module otelEnvironment8 * @module otelEnvironment
9 */9 */
10import * as Config from "effect/Config";10import * as Config from "effect/Config";
11import * as ConfigProvider from "effect/ConfigProvider";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
13import * as SchemaTransformation from "effect/SchemaTransformation";14import * as SchemaTransformation from "effect/SchemaTransformation";
...@@ -17,6 +18,8 @@ export interface OtelEnvironment {...@@ -17,6 +18,8 @@ export interface OtelEnvironment {
17 readonly disabled: boolean;18 readonly disabled: boolean;
18 /** Messages for the caller to log once at startup. */19 /** Messages for the caller to log once at startup. */
19 readonly warnings: ReadonlyArray<string>;20 readonly warnings: ReadonlyArray<string>;
21 /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */
22 readonly resourceAttributes: Readonly<Record<string, string>>;
20}23}
2124
22interface Flag {25interface Flag {
...@@ -64,6 +67,32 @@ const flag = (...@@ -64,6 +67,32 @@ const flag = (
64const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];67const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];
65const T3CODE_FALSE = ["false", "no", "off", "0", "n"];68const T3CODE_FALSE = ["false", "no", "off", "0", "n"];
6669
70const RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES";
71
72interface ResourceAttributes {
73 readonly value: Readonly<Record<string, string>>;
74 readonly warning?: string;
75}
76
77// The schema Effect's OTLP exporters read this variable with.
78const resourceAttributes = Config.Record(
79 Schema.StringFromUriComponent,
80 Schema.StringFromUriComponent,
81 RESOURCE_ATTRIBUTES,
82).pipe(
83 Config.map((value): ResourceAttributes => ({ value })),
84 Config.orElse(() =>
85 Config.String(RESOURCE_ATTRIBUTES).pipe(
86 // The value is left out because attributes can carry credentials.
87 Config.map((): ResourceAttributes => ({
88 value: {},
89 warning: `${RESOURCE_ATTRIBUTES} is not a list of percent-encoded key=value pairs and was ignored`,
90 })),
91 ),
92 ),
93 Config.withDefault<ResourceAttributes>({ value: {} }),
94);
95
67export const load: Effect.Effect<OtelEnvironment> = Config.all({96export const load: Effect.Effect<OtelEnvironment> = Config.all({
68 t3: flag(97 t3: flag(
69 "T3CODE_OTEL_SDK_DISABLED",98 "T3CODE_OTEL_SDK_DISABLED",
...@@ -81,10 +110,13 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({...@@ -81,10 +110,13 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
81 (value) =>110 (value) =>
82 `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`,111 `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`,
83 ),112 ),
113 resource: resourceAttributes,
84}).pipe(114}).pipe(
85 Effect.map(({ t3, spec }) => {115 Effect.map(({ t3, spec, resource }) => {
86 const disabled = t3.value ?? spec.value ?? false;116 const disabled = t3.value ?? spec.value ?? false;
87 const warnings = [t3.warning, spec.warning].filter((warning) => warning !== undefined);117 const warnings = [t3.warning, spec.warning, resource.warning].filter(
118 (warning) => warning !== undefined,
119 );
88 if (disabled) {120 if (disabled) {
89 warnings.push(121 warnings.push(
90 t3.value122 t3.value
...@@ -92,14 +124,34 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({...@@ -92,14 +124,34 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
92 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",124 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
93 );125 );
94 }126 }
95 return { disabled, warnings };127 return { disabled, warnings, resourceAttributes: resource.value };
96 }),128 }),
97 // Every read above falls back instead of failing, so this cannot happen.129 // Every read above falls back instead of failing, so this cannot happen.
98 Effect.orDie,130 Effect.orDie,
99);131);
100132
133/**
134 * Provide this around Effect's OTLP exporters, which read
135 * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode,
136 * so they see what `load` accepted instead.
137 */
138export const layerResourceAttributes = (attributes: Readonly<Record<string, string>>) =>
139 ConfigProvider.layerAdd(
140 ConfigProvider.fromEnv({
141 env: {
142 [RESOURCE_ATTRIBUTES]: Object.entries(attributes)
143 .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
144 .join(","),
145 },
146 // Keeps an emptied list from falling through to the raw value.
147 preserveEmptyStrings: true,
148 }),
149 { asPrimary: true },
150 );
151
101/** An environment that asked for nothing, for tests and for the pairing CLI. */152/** An environment that asked for nothing, for tests and for the pairing CLI. */
102export const none: OtelEnvironment = {153export const none: OtelEnvironment = {
103 disabled: false,154 disabled: false,
104 warnings: [],155 warnings: [],
156 resourceAttributes: {},
105};157};