From 18e00cd48c1074384fb33ca20adef972a27d9259 Mon Sep 17 00:00:00 2001 From: Ben Miner Date: Fri, 3 Apr 2026 16:36:38 -0500 Subject: [PATCH 1/3] feat: add tracesSampler callback and integrations array to SentryConfig --- src/__tests__/filtering.test.ts | 73 ++++++++++++++++++++++++++++++++- src/config.ts | 2 + src/provider.ts | 14 ++++++- src/types.ts | 22 ++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/__tests__/filtering.test.ts b/src/__tests__/filtering.test.ts index 522fdb7..e239c51 100644 --- a/src/__tests__/filtering.test.ts +++ b/src/__tests__/filtering.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' const DEFAULT_FILTER_CONFIG = { ignoredRoutes: ['/health', '/health/liveness', '/metrics'], @@ -230,4 +230,75 @@ describe('Observability Filtering', () => { ).toBe(false) }) }) + + describe('custom tracesSampler hook', () => { + type SamplerContext = { + name?: string + attributes?: Record + } + + // Replicates the composition logic from provider.ts initializeSentry + const makeSampler = ( + ignoredRoutes: string[], + customSampler?: (ctx: SamplerContext) => number | undefined, + defaultRate = 1.0, + ) => { + return (ctx: SamplerContext): number => { + const httpTarget = ctx.attributes?.['http.target'] as string | undefined + const rawRoute = httpTarget || ctx.name || '' + const route = rawRoute.replace(/\/+$/, '') + + for (const ignored of ignoredRoutes) { + if (route === ignored || route.startsWith(`${ignored}?`)) { + return 0 + } + } + + if (customSampler) { + const result = customSampler(ctx) + if (result !== undefined) return result + } + + return defaultRate + } + } + + it('falls back to sampleRate when no custom sampler provided', () => { + const sampler = makeSampler(['/health'], undefined, 0.5) + expect(sampler({ name: '/api/data' })).toBe(0.5) + }) + + it('custom sampler return value overrides sampleRate', () => { + const custom = (ctx: SamplerContext) => { + if (ctx.attributes?.['customer.id'] === '84') return 0 + return undefined + } + const sampler = makeSampler([], custom, 0.5) + expect(sampler({ attributes: { 'customer.id': '84' } })).toBe(0) + expect(sampler({ attributes: { 'customer.id': '1' } })).toBe(0.5) + }) + + it('custom sampler returning undefined falls back to sampleRate', () => { + const custom = (_ctx: SamplerContext) => undefined + const sampler = makeSampler([], custom, 0.75) + expect(sampler({ name: '/api/data' })).toBe(0.75) + }) + + it('ignoredRoutes check short-circuits before custom sampler is called', () => { + const custom = vi.fn(() => 1.0 as number | undefined) + const sampler = makeSampler(['/health'], custom, 0.5) + expect(sampler({ name: '/health' })).toBe(0) + expect(custom).not.toHaveBeenCalled() + }) + + it('custom sampler can return fractional sample rates', () => { + const custom = (ctx: SamplerContext) => { + if (ctx.attributes?.['tier'] === 'premium') return 1.0 + return 0.1 + } + const sampler = makeSampler([], custom, 0.5) + expect(sampler({ attributes: { tier: 'premium' } })).toBe(1.0) + expect(sampler({ attributes: { tier: 'free' } })).toBe(0.1) + }) + }) }) diff --git a/src/config.ts b/src/config.ts index 289515a..e7d06f5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,8 @@ export function resolveConfig(config: ObservabilityConfig): ResolvedConfig { enabled: sentryEnabled, sampleRate: sentrySampleRate, profileSampleRate: config.sentry?.profileSampleRate ?? sentrySampleRate, + tracesSampler: config.sentry?.tracesSampler, + integrations: config.sentry?.integrations ?? [], }, pyroscope: { diff --git a/src/provider.ts b/src/provider.ts index 1d33f9d..bf0faf3 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -95,7 +95,11 @@ function initializeSentry(config: ResolvedConfig): void { dsn: config.sentry.dsn, environment: config.environment, release: config.release, - integrations: [Sentry.expressIntegration(), nodeProfilingIntegration()], + integrations: [ + Sentry.expressIntegration(), + nodeProfilingIntegration(), + ...config.sentry.integrations, + ], enabled: config.sentry.enabled, tracesSampler: (samplingContext) => { const { name, attributes } = samplingContext @@ -108,6 +112,14 @@ function initializeSentry(config: ResolvedConfig): void { return 0 } } + + if (config.sentry.tracesSampler) { + const result = config.sentry.tracesSampler(samplingContext) + if (result !== undefined) { + return result + } + } + return config.sentry.sampleRate }, beforeSend(event, hint) { diff --git a/src/types.ts b/src/types.ts index 915f8fc..db50261 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,10 @@ import type { Span as OtelSpan } from '@opentelemetry/api' +import type { NodeOptions } from '@sentry/node' + +type SamplingContext = Parameters>[0] +type SentryIntegration = NonNullable< + Extract +>[number] /** Sentry error monitoring and profiling configuration. */ export interface SentryConfig { @@ -19,6 +25,20 @@ export interface SentryConfig { * Defaults to the resolved `sampleRate`. */ profileSampleRate?: number + /** + * Custom trace sampler. Receives the Sentry SamplingContext and returns a + * sample rate (0–1), or undefined to fall back to the default route-based + * sampler. + * + * Called AFTER the built-in ignoredRoutes check. If ignoredRoutes already + * returns 0, this callback is not invoked. + */ + tracesSampler?: (context: SamplingContext) => number | undefined + /** + * Additional Sentry integrations registered alongside the defaults + * (expressIntegration, nodeProfilingIntegration). + */ + integrations?: SentryIntegration[] } /** Pyroscope continuous profiling configuration. */ @@ -126,6 +146,8 @@ export interface ResolvedConfig { enabled: boolean sampleRate: number profileSampleRate: number + tracesSampler?: (context: SamplingContext) => number | undefined + integrations: SentryIntegration[] } pyroscope: { From 6c0440582ede751172c627a462b35af6a5832e64 Mon Sep 17 00:00:00 2001 From: Ben Miner Date: Fri, 3 Apr 2026 16:36:43 -0500 Subject: [PATCH 2/3] chore: relax node engine requirement to >= 20 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3fdab5b..e480e53 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">= 24" + "node": ">= 20" }, "publishConfig": { "@scope3data:registry": "https://npm.pkg.github.com", From 6cc4c9b558165efdef42d28ba4903b2fb606923d Mon Sep 17 00:00:00 2001 From: Ben Miner Date: Fri, 3 Apr 2026 16:40:37 -0500 Subject: [PATCH 3/3] chore: bump version to 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e480e53..ede8219 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scope3data/observability-js", - "version": "1.0.0", + "version": "1.1.0", "description": "Unified observability (Sentry, OpenTelemetry, Pyroscope) for Node.js services", "keywords": [ "observability",