diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 68cdff0e..ca8fc72e 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -3,6 +3,7 @@ import type { NextPage } from 'next'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import { NextAdapter } from 'next-query-params'; +import { env } from 'next-runtime-env'; import randomUUID from 'crypto-randomuuid'; import { enableMapSet } from 'immer'; import { QueryParamProvider } from 'use-query-params'; @@ -16,7 +17,7 @@ import { import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { DynamicFavicon } from '@/components/DynamicFavicon'; -import { IS_LOCAL_MODE } from '@/config'; +import { IS_LOCAL_MODE, parseResourceAttributes } from '@/config'; import { DEFAULT_FONT_VAR, FONT_VAR_MAP, @@ -136,12 +137,19 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { .then(res => res.json()) .then((_jsonData?: NextApiConfigResponseData) => { if (_jsonData?.apiKey) { + const frontendAttrs = parseResourceAttributes( + env('NEXT_PUBLIC_OTEL_RESOURCE_ATTRIBUTES') ?? '', + ); HyperDX.init({ apiKey: _jsonData.apiKey, consoleCapture: true, maskAllInputs: true, maskAllText: true, + // service.version is applied last so it always reflects the + // NEXT_PUBLIC_APP_VERSION and cannot be overridden by + // NEXT_PUBLIC_OTEL_RESOURCE_ATTRIBUTES. otelResourceAttributes: { + ...frontendAttrs, 'service.version': process.env.NEXT_PUBLIC_APP_VERSION, }, service: _jsonData.serviceName, diff --git a/packages/app/src/__tests__/config.test.ts b/packages/app/src/__tests__/config.test.ts new file mode 100644 index 00000000..e72d757b --- /dev/null +++ b/packages/app/src/__tests__/config.test.ts @@ -0,0 +1,83 @@ +import { parseResourceAttributes } from '@/config'; + +describe('parseResourceAttributes', () => { + it('parses a standard comma-separated string', () => { + const raw = + 'service.namespace=observability,deployment.environment=prod,k8s.cluster.name=us-west-2'; + expect(parseResourceAttributes(raw)).toEqual({ + 'service.namespace': 'observability', + 'deployment.environment': 'prod', + 'k8s.cluster.name': 'us-west-2', + }); + }); + + it('returns an empty object for an empty string', () => { + expect(parseResourceAttributes('')).toEqual({}); + }); + + it('handles a single key=value pair', () => { + expect(parseResourceAttributes('foo=bar')).toEqual({ foo: 'bar' }); + }); + + it('handles values containing equals signs', () => { + expect(parseResourceAttributes('url=https://example.com?a=1')).toEqual({ + url: 'https://example.com?a=1', + }); + }); + + it('skips malformed entries without an equals sign', () => { + expect(parseResourceAttributes('good=value,badentry,ok=yes')).toEqual({ + good: 'value', + ok: 'yes', + }); + }); + + it('skips entries where key is empty (leading equals)', () => { + expect(parseResourceAttributes('=nokey,valid=value')).toEqual({ + valid: 'value', + }); + }); + + it('handles trailing commas gracefully', () => { + expect(parseResourceAttributes('a=1,b=2,')).toEqual({ a: '1', b: '2' }); + }); + + it('handles leading commas gracefully', () => { + expect(parseResourceAttributes(',a=1,b=2')).toEqual({ a: '1', b: '2' }); + }); + + it('allows empty values', () => { + expect(parseResourceAttributes('key=')).toEqual({ key: '' }); + }); + + it('last value wins for duplicate keys', () => { + expect(parseResourceAttributes('k=first,k=second')).toEqual({ + k: 'second', + }); + }); + + it('decodes percent-encoded commas in values', () => { + expect(parseResourceAttributes('tags=a%2Cb%2Cc')).toEqual({ + tags: 'a,b,c', + }); + }); + + it('decodes percent-encoded equals in values', () => { + expect(parseResourceAttributes('expr=x%3D1')).toEqual({ + expr: 'x=1', + }); + }); + + it('decodes percent-encoded keys', () => { + expect(parseResourceAttributes('my%2Ekey=value')).toEqual({ + 'my.key': 'value', + }); + }); + + it('round-trips values with both encoded commas and equals', () => { + expect(parseResourceAttributes('q=a%3D1%2Cb%3D2,other=plain')).toEqual({ + q: 'a=1,b=2', + other: 'plain', + }); + }); +}); diff --git a/packages/app/src/config.ts b/packages/app/src/config.ts index 36fca96c..653aed63 100644 --- a/packages/app/src/config.ts +++ b/packages/app/src/config.ts @@ -14,6 +14,25 @@ export const HDX_SERVICE_NAME = process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME ?? 'hdx-oss-dev-app'; export const HDX_EXPORTER_ENABLED = (process.env.HDX_EXPORTER_ENABLED ?? 'true') === 'true'; + +export function parseResourceAttributes(raw: string): Record { + return raw + .split(',') + .filter(Boolean) + .reduce( + (acc, pair) => { + const idx = pair.indexOf('='); + if (idx > 0) { + acc[decodeURIComponent(pair.slice(0, idx))] = decodeURIComponent( + pair.slice(idx + 1), + ); + } + return acc; + }, + {} as Record, + ); +} + export const HDX_COLLECTOR_URL = process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??