Skip to content

Commit 78db0fb

Browse files
authored
feat: add tracesSampler callback and integrations array to SentryConfig (#5)
* feat: add tracesSampler callback and integrations array to SentryConfig * chore: relax node engine requirement to >= 20 * chore: bump version to 1.1.0
1 parent 35317c9 commit 78db0fb

5 files changed

Lines changed: 111 additions & 4 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@scope3data/observability-js",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "Unified observability (Sentry, OpenTelemetry, Pyroscope) for Node.js services",
55
"keywords": [
66
"observability",
@@ -70,7 +70,7 @@
7070
"vitest": "^4.0.18"
7171
},
7272
"engines": {
73-
"node": ">= 24"
73+
"node": ">= 20"
7474
},
7575
"publishConfig": {
7676
"@scope3data:registry": "https://npm.pkg.github.com",

src/__tests__/filtering.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi } from 'vitest'
22

33
const DEFAULT_FILTER_CONFIG = {
44
ignoredRoutes: ['/health', '/health/liveness', '/metrics'],
@@ -230,4 +230,75 @@ describe('Observability Filtering', () => {
230230
).toBe(false)
231231
})
232232
})
233+
234+
describe('custom tracesSampler hook', () => {
235+
type SamplerContext = {
236+
name?: string
237+
attributes?: Record<string, unknown>
238+
}
239+
240+
// Replicates the composition logic from provider.ts initializeSentry
241+
const makeSampler = (
242+
ignoredRoutes: string[],
243+
customSampler?: (ctx: SamplerContext) => number | undefined,
244+
defaultRate = 1.0,
245+
) => {
246+
return (ctx: SamplerContext): number => {
247+
const httpTarget = ctx.attributes?.['http.target'] as string | undefined
248+
const rawRoute = httpTarget || ctx.name || ''
249+
const route = rawRoute.replace(/\/+$/, '')
250+
251+
for (const ignored of ignoredRoutes) {
252+
if (route === ignored || route.startsWith(`${ignored}?`)) {
253+
return 0
254+
}
255+
}
256+
257+
if (customSampler) {
258+
const result = customSampler(ctx)
259+
if (result !== undefined) return result
260+
}
261+
262+
return defaultRate
263+
}
264+
}
265+
266+
it('falls back to sampleRate when no custom sampler provided', () => {
267+
const sampler = makeSampler(['/health'], undefined, 0.5)
268+
expect(sampler({ name: '/api/data' })).toBe(0.5)
269+
})
270+
271+
it('custom sampler return value overrides sampleRate', () => {
272+
const custom = (ctx: SamplerContext) => {
273+
if (ctx.attributes?.['customer.id'] === '84') return 0
274+
return undefined
275+
}
276+
const sampler = makeSampler([], custom, 0.5)
277+
expect(sampler({ attributes: { 'customer.id': '84' } })).toBe(0)
278+
expect(sampler({ attributes: { 'customer.id': '1' } })).toBe(0.5)
279+
})
280+
281+
it('custom sampler returning undefined falls back to sampleRate', () => {
282+
const custom = (_ctx: SamplerContext) => undefined
283+
const sampler = makeSampler([], custom, 0.75)
284+
expect(sampler({ name: '/api/data' })).toBe(0.75)
285+
})
286+
287+
it('ignoredRoutes check short-circuits before custom sampler is called', () => {
288+
const custom = vi.fn(() => 1.0 as number | undefined)
289+
const sampler = makeSampler(['/health'], custom, 0.5)
290+
expect(sampler({ name: '/health' })).toBe(0)
291+
expect(custom).not.toHaveBeenCalled()
292+
})
293+
294+
it('custom sampler can return fractional sample rates', () => {
295+
const custom = (ctx: SamplerContext) => {
296+
if (ctx.attributes?.['tier'] === 'premium') return 1.0
297+
return 0.1
298+
}
299+
const sampler = makeSampler([], custom, 0.5)
300+
expect(sampler({ attributes: { tier: 'premium' } })).toBe(1.0)
301+
expect(sampler({ attributes: { tier: 'free' } })).toBe(0.1)
302+
})
303+
})
233304
})

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export function resolveConfig(config: ObservabilityConfig): ResolvedConfig {
4747
enabled: sentryEnabled,
4848
sampleRate: sentrySampleRate,
4949
profileSampleRate: config.sentry?.profileSampleRate ?? sentrySampleRate,
50+
tracesSampler: config.sentry?.tracesSampler,
51+
integrations: config.sentry?.integrations ?? [],
5052
},
5153

5254
pyroscope: {

src/provider.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ function initializeSentry(config: ResolvedConfig): void {
9595
dsn: config.sentry.dsn,
9696
environment: config.environment,
9797
release: config.release,
98-
integrations: [Sentry.expressIntegration(), nodeProfilingIntegration()],
98+
integrations: [
99+
Sentry.expressIntegration(),
100+
nodeProfilingIntegration(),
101+
...config.sentry.integrations,
102+
],
99103
enabled: config.sentry.enabled,
100104
tracesSampler: (samplingContext) => {
101105
const { name, attributes } = samplingContext
@@ -108,6 +112,14 @@ function initializeSentry(config: ResolvedConfig): void {
108112
return 0
109113
}
110114
}
115+
116+
if (config.sentry.tracesSampler) {
117+
const result = config.sentry.tracesSampler(samplingContext)
118+
if (result !== undefined) {
119+
return result
120+
}
121+
}
122+
111123
return config.sentry.sampleRate
112124
},
113125
beforeSend(event, hint) {

src/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import type { Span as OtelSpan } from '@opentelemetry/api'
2+
import type { NodeOptions } from '@sentry/node'
3+
4+
type SamplingContext = Parameters<NonNullable<NodeOptions['tracesSampler']>>[0]
5+
type SentryIntegration = NonNullable<
6+
Extract<NodeOptions['integrations'], unknown[]>
7+
>[number]
28

39
/** Sentry error monitoring and profiling configuration. */
410
export interface SentryConfig {
@@ -19,6 +25,20 @@ export interface SentryConfig {
1925
* Defaults to the resolved `sampleRate`.
2026
*/
2127
profileSampleRate?: number
28+
/**
29+
* Custom trace sampler. Receives the Sentry SamplingContext and returns a
30+
* sample rate (0–1), or undefined to fall back to the default route-based
31+
* sampler.
32+
*
33+
* Called AFTER the built-in ignoredRoutes check. If ignoredRoutes already
34+
* returns 0, this callback is not invoked.
35+
*/
36+
tracesSampler?: (context: SamplingContext) => number | undefined
37+
/**
38+
* Additional Sentry integrations registered alongside the defaults
39+
* (expressIntegration, nodeProfilingIntegration).
40+
*/
41+
integrations?: SentryIntegration[]
2242
}
2343

2444
/** Pyroscope continuous profiling configuration. */
@@ -126,6 +146,8 @@ export interface ResolvedConfig {
126146
enabled: boolean
127147
sampleRate: number
128148
profileSampleRate: number
149+
tracesSampler?: (context: SamplingContext) => number | undefined
150+
integrations: SentryIntegration[]
129151
}
130152

131153
pyroscope: {

0 commit comments

Comments
 (0)