Skip to content

Commit 6e9a553

Browse files
authored
Support custom resource attributes for Hyperdx frontend app's internal telemetry (#2067)
1 parent c4a1311 commit 6e9a553

3 files changed

Lines changed: 111 additions & 1 deletion

File tree

packages/app/pages/_app.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { NextPage } from 'next';
33
import type { AppProps } from 'next/app';
44
import Head from 'next/head';
55
import { NextAdapter } from 'next-query-params';
6+
import { env } from 'next-runtime-env';
67
import randomUUID from 'crypto-randomuuid';
78
import { enableMapSet } from 'immer';
89
import { QueryParamProvider } from 'use-query-params';
@@ -16,7 +17,7 @@ import {
1617
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
1718

1819
import { DynamicFavicon } from '@/components/DynamicFavicon';
19-
import { IS_LOCAL_MODE } from '@/config';
20+
import { IS_LOCAL_MODE, parseResourceAttributes } from '@/config';
2021
import {
2122
DEFAULT_FONT_VAR,
2223
FONT_VAR_MAP,
@@ -136,12 +137,19 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
136137
.then(res => res.json())
137138
.then((_jsonData?: NextApiConfigResponseData) => {
138139
if (_jsonData?.apiKey) {
140+
const frontendAttrs = parseResourceAttributes(
141+
env('NEXT_PUBLIC_OTEL_RESOURCE_ATTRIBUTES') ?? '',
142+
);
139143
HyperDX.init({
140144
apiKey: _jsonData.apiKey,
141145
consoleCapture: true,
142146
maskAllInputs: true,
143147
maskAllText: true,
148+
// service.version is applied last so it always reflects the
149+
// NEXT_PUBLIC_APP_VERSION and cannot be overridden by
150+
// NEXT_PUBLIC_OTEL_RESOURCE_ATTRIBUTES.
144151
otelResourceAttributes: {
152+
...frontendAttrs,
145153
'service.version': process.env.NEXT_PUBLIC_APP_VERSION,
146154
},
147155
service: _jsonData.serviceName,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { parseResourceAttributes } from '@/config';
2+
3+
describe('parseResourceAttributes', () => {
4+
it('parses a standard comma-separated string', () => {
5+
const raw =
6+
'service.namespace=observability,deployment.environment=prod,k8s.cluster.name=us-west-2';
7+
expect(parseResourceAttributes(raw)).toEqual({
8+
'service.namespace': 'observability',
9+
'deployment.environment': 'prod',
10+
'k8s.cluster.name': 'us-west-2',
11+
});
12+
});
13+
14+
it('returns an empty object for an empty string', () => {
15+
expect(parseResourceAttributes('')).toEqual({});
16+
});
17+
18+
it('handles a single key=value pair', () => {
19+
expect(parseResourceAttributes('foo=bar')).toEqual({ foo: 'bar' });
20+
});
21+
22+
it('handles values containing equals signs', () => {
23+
expect(parseResourceAttributes('url=https://example.com?a=1')).toEqual({
24+
url: 'https://example.com?a=1',
25+
});
26+
});
27+
28+
it('skips malformed entries without an equals sign', () => {
29+
expect(parseResourceAttributes('good=value,badentry,ok=yes')).toEqual({
30+
good: 'value',
31+
ok: 'yes',
32+
});
33+
});
34+
35+
it('skips entries where key is empty (leading equals)', () => {
36+
expect(parseResourceAttributes('=nokey,valid=value')).toEqual({
37+
valid: 'value',
38+
});
39+
});
40+
41+
it('handles trailing commas gracefully', () => {
42+
expect(parseResourceAttributes('a=1,b=2,')).toEqual({ a: '1', b: '2' });
43+
});
44+
45+
it('handles leading commas gracefully', () => {
46+
expect(parseResourceAttributes(',a=1,b=2')).toEqual({ a: '1', b: '2' });
47+
});
48+
49+
it('allows empty values', () => {
50+
expect(parseResourceAttributes('key=')).toEqual({ key: '' });
51+
});
52+
53+
it('last value wins for duplicate keys', () => {
54+
expect(parseResourceAttributes('k=first,k=second')).toEqual({
55+
k: 'second',
56+
});
57+
});
58+
59+
it('decodes percent-encoded commas in values', () => {
60+
expect(parseResourceAttributes('tags=a%2Cb%2Cc')).toEqual({
61+
tags: 'a,b,c',
62+
});
63+
});
64+
65+
it('decodes percent-encoded equals in values', () => {
66+
expect(parseResourceAttributes('expr=x%3D1')).toEqual({
67+
expr: 'x=1',
68+
});
69+
});
70+
71+
it('decodes percent-encoded keys', () => {
72+
expect(parseResourceAttributes('my%2Ekey=value')).toEqual({
73+
'my.key': 'value',
74+
});
75+
});
76+
77+
it('round-trips values with both encoded commas and equals', () => {
78+
expect(parseResourceAttributes('q=a%3D1%2Cb%3D2,other=plain')).toEqual({
79+
q: 'a=1,b=2',
80+
other: 'plain',
81+
});
82+
});
83+
});

packages/app/src/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ export const HDX_SERVICE_NAME =
1414
process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME ?? 'hdx-oss-dev-app';
1515
export const HDX_EXPORTER_ENABLED =
1616
(process.env.HDX_EXPORTER_ENABLED ?? 'true') === 'true';
17+
18+
export function parseResourceAttributes(raw: string): Record<string, string> {
19+
return raw
20+
.split(',')
21+
.filter(Boolean)
22+
.reduce(
23+
(acc, pair) => {
24+
const idx = pair.indexOf('=');
25+
if (idx > 0) {
26+
acc[decodeURIComponent(pair.slice(0, idx))] = decodeURIComponent(
27+
pair.slice(idx + 1),
28+
);
29+
}
30+
return acc;
31+
},
32+
{} as Record<string, string>,
33+
);
34+
}
35+
1736
export const HDX_COLLECTOR_URL =
1837
process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT ??
1938
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??

0 commit comments

Comments
 (0)