Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -70,7 +70,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">= 24"
"node": ">= 20"
},
"publishConfig": {
"@scope3data:registry": "https://npm.pkg.github.com",
Expand Down
73 changes: 72 additions & 1 deletion src/__tests__/filtering.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down Expand Up @@ -230,4 +230,75 @@ describe('Observability Filtering', () => {
).toBe(false)
})
})

describe('custom tracesSampler hook', () => {
type SamplerContext = {
name?: string
attributes?: Record<string, unknown>
}

// 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)
})
})
})
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 13 additions & 1 deletion src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { Span as OtelSpan } from '@opentelemetry/api'
import type { NodeOptions } from '@sentry/node'

type SamplingContext = Parameters<NonNullable<NodeOptions['tracesSampler']>>[0]
type SentryIntegration = NonNullable<
Extract<NodeOptions['integrations'], unknown[]>
>[number]

/** Sentry error monitoring and profiling configuration. */
export interface SentryConfig {
Expand All @@ -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. */
Expand Down Expand Up @@ -126,6 +146,8 @@ export interface ResolvedConfig {
enabled: boolean
sampleRate: number
profileSampleRate: number
tracesSampler?: (context: SamplingContext) => number | undefined
integrations: SentryIntegration[]
}

pyroscope: {
Expand Down
Loading