Skip to content

Commit 3989c39

Browse files
committed
chore(config): invoke key-value parsing directly in config
chore(instrumentation): use cached singleton vs. global metrics
1 parent e166140 commit 3989c39

File tree

4 files changed

+28
-36
lines changed

4 files changed

+28
-36
lines changed

src/config.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { describe, it, expect } from "@jest/globals";
6-
import { getEnvEnum, getEnvInt, getEnvString, parseKVList } from "./config";
6+
import { getEnvEnum, getEnvInt, getEnvString, getKVList } from "./config";
77

88
describe("getEnvString", () => {
99
it("throws an error when attempting to access an undefined environment variable", () => {
@@ -118,26 +118,26 @@ describe("getEnvEnum", () => {
118118

119119
describe("parseKVList", () => {
120120
it("returns empty object for undefined", () => {
121-
expect(parseKVList(undefined)).toEqual({});
121+
expect(getKVList(undefined)).toEqual({});
122122
});
123123

124124
it("returns empty object for empty string", () => {
125-
expect(parseKVList("")).toEqual({});
125+
expect(getKVList("")).toEqual({});
126126
});
127127

128128
it("returns empty object for whitespace-only string", () => {
129-
expect(parseKVList(" \n\t ")).toEqual({});
129+
expect(getKVList(" \n\t ")).toEqual({});
130130
});
131131

132132
it("parses a single key=value pair", () => {
133-
expect(parseKVList("service.name=monitor")).toEqual({
133+
expect(getKVList("service.name=monitor")).toEqual({
134134
"service.name": "monitor",
135135
});
136136
});
137137

138138
it("parses multiple key=value pairs separated by commas", () => {
139139
expect(
140-
parseKVList(
140+
getKVList(
141141
"k8s.namespace.name=monitor-stage,k8s.pod.name=pod-123,service.version=123abcd",
142142
),
143143
).toEqual({
@@ -148,47 +148,47 @@ describe("parseKVList", () => {
148148
});
149149

150150
it("trims whitespace around segments but preserves whitespace inside values", () => {
151-
expect(parseKVList(" a=1 , b=hello world , c=3 ")).toEqual({
151+
expect(getKVList(" a=1 , b=hello world , c=3 ")).toEqual({
152152
a: "1",
153153
b: "hello world",
154154
c: "3",
155155
});
156156
});
157157

158158
it("ignores empty segments from consecutive commas", () => {
159-
expect(parseKVList("a=1,,b=2,,,c=3,")).toEqual({
159+
expect(getKVList("a=1,,b=2,,,c=3,")).toEqual({
160160
a: "1",
161161
b: "2",
162162
c: "3",
163163
});
164164
});
165165

166166
it("ignores segments without '='", () => {
167-
expect(parseKVList("flag,service.name=monitor")).toEqual({
167+
expect(getKVList("flag,service.name=monitor")).toEqual({
168168
"service.name": "monitor",
169169
});
170170
});
171171

172172
it("ignores keys with empty values (key=)", () => {
173-
expect(parseKVList("a=,b=2")).toEqual({
173+
expect(getKVList("a=,b=2")).toEqual({
174174
b: "2",
175175
});
176176
});
177177

178178
it("ignores empty keys (=value)", () => {
179-
expect(parseKVList("=value,ok=yes")).toEqual({
179+
expect(getKVList("=value,ok=yes")).toEqual({
180180
ok: "yes",
181181
});
182182
});
183183
it("properly preserves values with '='", () => {
184-
expect(parseKVList("auth=Bearer abc=def==,x=1")).toEqual({
184+
expect(getKVList("auth=Bearer abc=def==,x=1")).toEqual({
185185
auth: "Bearer abc=def==",
186186
x: "1",
187187
});
188188
});
189189
it("allows keys containing dots/slashes and other characters", () => {
190190
expect(
191-
parseKVList(
191+
getKVList(
192192
"http.request.header.user_agent=curl/8.7.1,service.namespace=monitor-stage",
193193
),
194194
).toEqual({
@@ -197,11 +197,11 @@ describe("parseKVList", () => {
197197
});
198198
});
199199
it("handles '=' string alone", () => {
200-
expect(parseKVList("=")).toEqual({});
200+
expect(getKVList("=")).toEqual({});
201201
});
202202

203203
it("handles mixed malformed and well-formed segments", () => {
204-
expect(parseKVList("a=1, ,flag,b=2,=nope,c=3")).toEqual({
204+
expect(getKVList("a=1, ,flag,b=2,=nope,c=3")).toEqual({
205205
a: "1",
206206
b: "2",
207207
c: "3",

src/config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export const config = {
131131
fallbackValue: "http/protobuf",
132132
},
133133
),
134-
resourceAttributes: process.env.OTEL_RESOURCE_ATTRIBUTES,
134+
resourceAttributes: getKVList(process.env.OTEL_RESOURCE_ATTRIBUTES),
135135
serviceName: getEnvString("OTEL_SERVICE_NAME", {
136136
fallbackValue: "monitor",
137137
}),
@@ -231,11 +231,12 @@ export function getEnvString(
231231

232232
/**
233233
* Parse comma-separated key=value list into dictionary.
234+
* Undefined or empty values will return an empty object.
234235
* Segments without '=' are ignored (a=1,b,c=2 -> {a: 1, c: 2}
235236
* (e.g. used by OTEL_RESOURCE_ATTRIBUTES env var:
236237
* 'k8s.container.name=monitor-api-endpoint,k8s.namespace.name=monitor-stage...')
237238
* */
238-
export function parseKVList(str?: string): Record<string, string> {
239+
export function getKVList(str?: string): Record<string, string> {
239240
if (str === undefined) return {};
240241
return str
241242
.split(",")

src/instrumentation.node.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import {
1010
ConsoleMetricExporter,
1111
PeriodicExportingMetricReader,
1212
} from "@opentelemetry/sdk-metrics";
13-
import { detectResources } from "@opentelemetry/resources";
14-
import { gcpDetector } from "@opentelemetry/resource-detector-gcp";
1513
import { resourceFromAttributes } from "@opentelemetry/resources";
1614
import { NodeSDK } from "@opentelemetry/sdk-node";
1715
import {
@@ -42,7 +40,7 @@ import {
4240
} from "@sentry/opentelemetry";
4341
import * as Sentry from "@sentry/nextjs";
4442
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
45-
import { config as appConfig, parseKVList } from "./config";
43+
import { config as appConfig } from "./config";
4644

4745
// Note: console.log use is intentional because this step is done as early
4846
// as possible, before setting up winston logging
@@ -101,7 +99,7 @@ export async function nodeSDKBuilder() {
10199
const sdk = new NodeSDK({
102100
resource: resourceFromAttributes({
103101
[ATTR_SERVICE_NAME]: appConfig.otel.serviceName,
104-
...parseKVList(appConfig.otel.resourceAttributes),
102+
...appConfig.otel.resourceAttributes,
105103
}),
106104
metricReaders: [
107105
new PeriodicExportingMetricReader({
@@ -170,21 +168,18 @@ export async function nodeSDKBuilder() {
170168
console.log("[INSTRUMENTATION] Otel setup is valid");
171169
}
172170

173-
// Prometheus setup
174171
type MetricsState = {
175172
hibpNotifyRequestsTotal: Counter<Attributes>;
176173
hibpNotifyRequestFailuresTotal: Counter<Attributes>;
177174
};
178175

179-
declare global {
180-
var __metrics: Readonly<MetricsState> | undefined;
181-
}
176+
let metricsState: MetricsState | undefined;
182177

183178
// NOTE: OTEL must be initialized before calling any metrics
184179
// or else it will be a No-Op metric
185-
function getOrInitMetrics(): MetricsState {
180+
export function getOrInitMetrics(): MetricsState {
186181
// Return cached state
187-
if (globalThis.__metrics !== undefined) return globalThis.__metrics;
182+
if (metricsState !== undefined) return metricsState;
188183
// Get the pre-registered metrics provider
189184
// Will be a no-op if otel has not been initialized
190185
const meter = metrics.getMeter(appConfig.otel.serviceName);
@@ -204,14 +199,6 @@ function getOrInitMetrics(): MetricsState {
204199
hibpNotifyRequestFailuresTotal,
205200
hibpNotifyRequestsTotal,
206201
};
207-
208-
// Make it readonly
209-
Object.defineProperty(globalThis, "__metrics", {
210-
value: state,
211-
writable: false,
212-
configurable: false,
213-
enumerable: false,
214-
});
215202
return state;
216203
}
217204

src/instrumentation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,13 @@ export async function register() {
3131
// Disable Sentry's automatic OTEL instrumentation since we're managing it ourselves
3232
skipOpenTelemetrySetup: true,
3333
});
34-
const { nodeSDKBuilder } = await import("./instrumentation.node");
34+
const { nodeSDKBuilder, getOrInitMetrics } = await import(
35+
"./instrumentation.node"
36+
);
3537
console.log("[INSTRUMENTATION] Initializing OTEL...");
3638
await nodeSDKBuilder();
39+
// Ensure metrics are initialized after OTEL
40+
getOrInitMetrics();
3741
}
3842
}
3943

0 commit comments

Comments
 (0)