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<
356356const resolveOtlpEndpoints = Effect.gen(function* () {
357357 const otel = yield* OtelEnvironment.load;
358358 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 };
360366 }
361367
362368 const environment = yield* DesktopEnvironment.DesktopEnvironment;
......@@ -366,6 +372,7 @@ const resolveOtlpEndpoints = Effect.gen(function* () {
366372 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
367373 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
368374 warnings: otel.warnings,
375 resourceAttributes: otel.resourceAttributes,
369376 };
370377});
371378
......@@ -683,7 +690,10 @@ const telemetryLayer = Layer.unwrap(
683690 Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)),
684691 );
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 );
687697 }),
688698);
689699
apps/server/src/observability/Layers/Observability.ts+4
......@@ -4,6 +4,7 @@ import {
44 makeTraceSink,
55 otlpSerializationLayer,
66} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
78import * as Effect from "effect/Effect";
89import * as Layer from "effect/Layer";
910import * as References from "effect/References";
......@@ -96,6 +97,9 @@ export const ObservabilityLive = Layer.unwrap(
9697 Layer.provideMerge(
9798 Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer),
9899 ),
100 Layer.provide(
101 OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes),
102 ),
99103 );
100104 }),
101105);
packages/shared/src/otelEnvironment.test.ts+49
......@@ -1,6 +1,8 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as ConfigProvider from "effect/ConfigProvider";
33import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as OtlpResource from "effect/unstable/observability/OtlpResource";
46
57import * as OtelEnvironment from "./otelEnvironment.ts";
68
......@@ -79,4 +81,51 @@ describe("OtelEnvironment", () => {
7981 assert.deepStrictEqual(resolved.warnings, warnings);
8082 }),
8183 );
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 });
82131});
packages/shared/src/otelEnvironment.ts+55-3
......@@ -8,6 +8,7 @@
88 * @module otelEnvironment
99 */
1010import * as Config from "effect/Config";
11import * as ConfigProvider from "effect/ConfigProvider";
1112import * as Effect from "effect/Effect";
1213import * as Schema from "effect/Schema";
1314import * as SchemaTransformation from "effect/SchemaTransformation";
......@@ -17,6 +18,8 @@ export interface OtelEnvironment {
1718 readonly disabled: boolean;
1819 /** Messages for the caller to log once at startup. */
1920 readonly warnings: ReadonlyArray<string>;
21 /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */
22 readonly resourceAttributes: Readonly<Record<string, string>>;
2023}
2124
2225interface Flag {
......@@ -64,6 +67,32 @@ const flag = (
6467const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];
6568const 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
6796export const load: Effect.Effect<OtelEnvironment> = Config.all({
6897 t3: flag(
6998 "T3CODE_OTEL_SDK_DISABLED",
......@@ -81,10 +110,13 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
81110 (value) =>
82111 `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`,
83112 ),
113 resource: resourceAttributes,
84114}).pipe(
85 Effect.map(({ t3, spec }) => {
115 Effect.map(({ t3, spec, resource }) => {
86116 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 );
88120 if (disabled) {
89121 warnings.push(
90122 t3.value
......@@ -92,14 +124,34 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
92124 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
93125 );
94126 }
95 return { disabled, warnings };
127 return { disabled, warnings, resourceAttributes: resource.value };
96128 }),
97129 // Every read above falls back instead of failing, so this cannot happen.
98130 Effect.orDie,
99131);
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
101152/** An environment that asked for nothing, for tests and for the pairing CLI. */
102153export const none: OtelEnvironment = {
103154 disabled: false,
104155 warnings: [],
156 resourceAttributes: {},
105157};