| author | |
| committer | |
| log | 87d84280192195b338e27acf2333771a13d04e61 |
| tree | 008a412c649bfef81f313b2df209752ea4eaad83 |
| parent | 3412097bff7ffa62ef79e735c65d1e5cff71c356 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 356 | const resolveOtlpEndpoints = Effect.gen(function* () { |
| 357 | 357 | const otel = yield* OtelEnvironment.load; |
| 358 | 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 | } |
| 361 | 367 | |
| 362 | 368 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| ... | ... | @@ -366,6 +372,7 @@ const resolveOtlpEndpoints = Effect.gen(function* () { |
| 366 | 372 | metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, |
| 367 | 373 | logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, |
| 368 | 374 | warnings: otel.warnings, |
| 375 | resourceAttributes: otel.resourceAttributes, | |
| 369 | 376 | }; |
| 370 | 377 | }); |
| 371 | 378 | |
| ... | ... | @@ -683,7 +690,10 @@ const telemetryLayer = Layer.unwrap( |
| 683 | 690 | Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), |
| 684 | 691 | ); |
| 685 | 692 | |
| 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 | ); |
| 689 | 699 |
apps/server/src/observability/Layers/Observability.ts+4| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | makeTraceSink, |
| 5 | 5 | otlpSerializationLayer, |
| 6 | 6 | } from "@t3tools/shared/observability"; |
| 7 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 7 | 8 | import * as Effect from "effect/Effect"; |
| 8 | 9 | import * as Layer from "effect/Layer"; |
| 9 | 10 | import * as References from "effect/References"; |
| ... | ... | @@ -96,6 +97,9 @@ export const ObservabilityLive = Layer.unwrap( |
| 96 | 97 | Layer.provideMerge( |
| 97 | 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 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as OtlpResource from "effect/unstable/observability/OtlpResource"; | |
| 4 | 6 | |
| 5 | 7 | import * as OtelEnvironment from "./otelEnvironment.ts"; |
| 6 | 8 | |
| ... | ... | @@ -79,4 +81,51 @@ describe("OtelEnvironment", () => { |
| 79 | 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 | 8 | * @module otelEnvironment |
| 9 | 9 | */ |
| 10 | 10 | import * as Config from "effect/Config"; |
| 11 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Schema from "effect/Schema"; |
| 13 | 14 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| ... | ... | @@ -17,6 +18,8 @@ export interface OtelEnvironment { |
| 17 | 18 | readonly disabled: boolean; |
| 18 | 19 | /** Messages for the caller to log once at startup. */ |
| 19 | 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 | } |
| 21 | 24 | |
| 22 | 25 | interface Flag { |
| ... | ... | @@ -64,6 +67,32 @@ const flag = ( |
| 64 | 67 | const T3CODE_TRUE = ["true", "yes", "on", "1", "y"]; |
| 65 | 68 | const T3CODE_FALSE = ["false", "no", "off", "0", "n"]; |
| 66 | 69 | |
| 70 | const RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES"; | |
| 71 | ||
| 72 | interface 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. | |
| 78 | const 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 | ||
| 67 | 96 | export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 68 | 97 | t3: flag( |
| 69 | 98 | "T3CODE_OTEL_SDK_DISABLED", |
| ... | ... | @@ -81,10 +110,13 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 81 | 110 | (value) => |
| 82 | 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 | 114 | }).pipe( |
| 85 | Effect.map(({ t3, spec }) => { | |
| 115 | Effect.map(({ t3, spec, resource }) => { | |
| 86 | 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 | 120 | if (disabled) { |
| 89 | 121 | warnings.push( |
| 90 | 122 | t3.value |
| ... | ... | @@ -92,14 +124,34 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 92 | 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 | 129 | // Every read above falls back instead of failing, so this cannot happen. |
| 98 | 130 | Effect.orDie, |
| 99 | 131 | ); |
| 100 | 132 | |
| 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 | */ | |
| 138 | export 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 | 152 | /** An environment that asked for nothing, for tests and for the pairing CLI. */ |
| 102 | 153 | export const none: OtelEnvironment = { |
| 103 | 154 | disabled: false, |
| 104 | 155 | warnings: [], |
| 156 | resourceAttributes: {}, | |
| 105 | 157 | }; |