diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index 34dfae6cb7c8..58b21991a8f4 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -17,6 +17,7 @@ export type EffectClientLayerOptions = BrowserOptions; * This layer provides Effect applications with full Sentry instrumentation including: * - Effect spans traced as Sentry spans * - Effect logs forwarded to Sentry (when `enableLogs` is set) + * - Effect metrics sent to Sentry (when `enableMetrics` is set) * * @example * ```typescript diff --git a/packages/effect/src/metrics.ts b/packages/effect/src/metrics.ts new file mode 100644 index 000000000000..f105b6a65eff --- /dev/null +++ b/packages/effect/src/metrics.ts @@ -0,0 +1,135 @@ +import { metrics as sentryMetrics } from '@sentry/core'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import { scopedDiscard } from 'effect/Layer'; +import * as Metric from 'effect/Metric'; +import * as MetricKeyType from 'effect/MetricKeyType'; +import type * as MetricPair from 'effect/MetricPair'; +import * as MetricState from 'effect/MetricState'; +import * as Schedule from 'effect/Schedule'; + +type MetricAttributes = Record; + +function labelsToAttributes(labels: ReadonlyArray<{ key: string; value: string }>): MetricAttributes { + return labels.reduce((acc, label) => ({ ...acc, [label.key]: label.value }), {}); +} + +function sendMetricToSentry(pair: MetricPair.MetricPair.Untyped): void { + const { metricKey, metricState } = pair; + const name = metricKey.name; + const attributes = labelsToAttributes(metricKey.tags); + + if (MetricState.isCounterState(metricState)) { + const value = typeof metricState.count === 'bigint' ? Number(metricState.count) : metricState.count; + sentryMetrics.count(name, value, { attributes }); + } else if (MetricState.isGaugeState(metricState)) { + const value = typeof metricState.value === 'bigint' ? Number(metricState.value) : metricState.value; + sentryMetrics.gauge(name, value, { attributes }); + } else if (MetricState.isHistogramState(metricState)) { + sentryMetrics.distribution(`${name}.sum`, metricState.sum, { attributes }); + sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes }); + sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes }); + sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes }); + } else if (MetricState.isSummaryState(metricState)) { + sentryMetrics.distribution(`${name}.sum`, metricState.sum, { attributes }); + sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes }); + sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes }); + sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes }); + } else if (MetricState.isFrequencyState(metricState)) { + for (const [word, count] of metricState.occurrences) { + sentryMetrics.count(name, count, { + attributes: { ...attributes, word }, + }); + } + } +} + +function getMetricId(pair: MetricPair.MetricPair.Untyped): string { + const tags = pair.metricKey.tags.map(t => `${t.key}=${t.value}`).join(','); + return `${pair.metricKey.name}:${tags}`; +} + +function sendDeltaMetricToSentry( + pair: MetricPair.MetricPair.Untyped, + previousCounterValues: Map, +): void { + const { metricKey, metricState } = pair; + const name = metricKey.name; + const attributes = labelsToAttributes(metricKey.tags); + const metricId = getMetricId(pair); + + if (MetricState.isCounterState(metricState)) { + const currentValue = typeof metricState.count === 'bigint' ? Number(metricState.count) : metricState.count; + + const previousValue = previousCounterValues.get(metricId) ?? 0; + const delta = currentValue - previousValue; + + if (delta > 0) { + sentryMetrics.count(name, delta, { attributes }); + } + + previousCounterValues.set(metricId, currentValue); + } else { + sendMetricToSentry(pair); + } +} + +/** + * Flushes all Effect metrics to Sentry. + * @param previousCounterValues - Map tracking previous counter values for delta calculation + */ +function flushMetricsToSentry(previousCounterValues: Map): void { + const snapshot = Metric.unsafeSnapshot(); + + snapshot.forEach((pair: MetricPair.MetricPair.Untyped) => { + if (MetricKeyType.isCounterKey(pair.metricKey.keyType)) { + sendDeltaMetricToSentry(pair, previousCounterValues); + } else { + sendMetricToSentry(pair); + } + }); +} + +/** + * Creates a metrics flusher with its own isolated state for delta tracking. + * Useful for testing scenarios where you need to control the lifecycle. + * @internal + */ +export function createMetricsFlusher(): { + flush: () => void; + clear: () => void; +} { + const previousCounterValues = new Map(); + return { + flush: () => flushMetricsToSentry(previousCounterValues), + clear: () => previousCounterValues.clear(), + }; +} + +function createMetricsReporterEffect(previousCounterValues: Map): Effect.Effect { + const schedule = Schedule.spaced('10 seconds'); + + return Effect.repeat( + Effect.sync(() => flushMetricsToSentry(previousCounterValues)), + schedule, + ).pipe(Effect.asVoid, Effect.interruptible); +} + +/** + * Effect Layer that periodically flushes metrics to Sentry. + * The layer manages its own state for delta counter calculations, + * which is automatically cleaned up when the layer is finalized. + */ +export const SentryEffectMetricsLayer: Layer.Layer = scopedDiscard( + Effect.gen(function* () { + const previousCounterValues = new Map(); + + yield* Effect.acquireRelease(Effect.void, () => + Effect.sync(() => { + previousCounterValues.clear(); + }), + ); + + yield* Effect.forkScoped(createMetricsReporterEffect(previousCounterValues)); + }), +); diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index 2dcca1f7a4e2..5cc281560f18 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -16,6 +16,7 @@ export type EffectServerLayerOptions = NodeOptions; * This layer provides Effect applications with full Sentry instrumentation including: * - Effect spans traced as Sentry spans * - Effect logs forwarded to Sentry (when `enableLogs` is set) + * * @example * ```typescript diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 475d2d2a70c3..44393fe25731 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -2,14 +2,16 @@ import type * as EffectLayer from 'effect/Layer'; import { empty as emptyLayer, provideMerge } from 'effect/Layer'; import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; import { SentryEffectLogger } from '../logger'; +import { SentryEffectMetricsLayer } from '../metrics'; import { SentryEffectTracerLayer } from '../tracer'; export interface EffectLayerBaseOptions { enableLogs?: boolean; + enableMetrics?: boolean; } /** - * Builds an Effect layer that integrates Sentry tracing and logging. + * Builds an Effect layer that integrates Sentry tracing, logging, and metrics. * * Returns an empty layer if no Sentry client is available. Otherwise, starts with * the Sentry tracer layer and optionally merges logging and metrics layers based @@ -23,7 +25,7 @@ export function buildEffectLayer( return emptyLayer; } - const { enableLogs = false } = options; + const { enableLogs = false, enableMetrics = true } = options; let layer: EffectLayer.Layer = SentryEffectTracerLayer; if (enableLogs) { @@ -31,5 +33,9 @@ export function buildEffectLayer( layer = layer.pipe(provideMerge(effectLogger)); } + if (enableMetrics) { + layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); + } + return layer; } diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts index 9875cfe5b14b..a42aa7e82e26 100644 --- a/packages/effect/test/buildEffectLayer.test.ts +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -48,8 +48,22 @@ describe('buildEffectLayer', () => { expect(Layer.isLayer(layer)).toBe(true); }); + it('returns a valid layer with enableMetrics: false', () => { + const layer = buildEffectLayer({ enableMetrics: false }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + + it('returns a valid layer with enableMetrics: true', () => { + const layer = buildEffectLayer({ enableMetrics: true }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + it('returns a valid layer with all features enabled', () => { - const layer = buildEffectLayer({ enableLogs: true }, mockClient); + const layer = buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); @@ -71,20 +85,18 @@ describe('buildEffectLayer', () => { }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))), ); - it.effect('layer with logs disabled routes Effect does not log to Sentry logger', () => - Effect.gen(function* () { - const infoSpy = vi.spyOn(sentryLogger, 'info'); - yield* Effect.log('test log message'); - expect(infoSpy).not.toHaveBeenCalled(); - infoSpy.mockRestore(); - }).pipe(Effect.provide(buildEffectLayer({ enableLogs: false }, mockClient))), - ); + it('returns different layer when enableMetrics is true vs false', () => { + const layerWithMetrics = buildEffectLayer({ enableMetrics: true }, mockClient); + const layerWithoutMetrics = buildEffectLayer({ enableMetrics: false }, mockClient); + + expect(layerWithMetrics).not.toBe(layerWithoutMetrics); + }); it.effect('layer with all features enabled can be provided to an Effect program', () => Effect.gen(function* () { const result = yield* Effect.succeed('all-features'); expect(result).toBe('all-features'); - }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))), + }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient))), ); it.effect('layer enables tracing for Effect spans via Sentry tracer', () => @@ -109,9 +121,10 @@ describe('buildEffectLayer', () => { const layer = buildEffectLayer( { enableLogs: true, + enableMetrics: true, dsn: 'https://test@sentry.io/123', debug: true, - } as { enableLogs?: boolean; dsn?: string; debug?: boolean }, + } as { enableLogs?: boolean; enableMetrics?: boolean; dsn?: string; debug?: boolean }, mockClient, ); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index 072d8becb601..8d96f039062b 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -74,6 +74,7 @@ describe.each([ dsn: TEST_DSN, transport: getMockTransport(), enableLogs: true, + enableMetrics: true, }); expect(layer).toBeDefined(); diff --git a/packages/effect/test/metrics.test.ts b/packages/effect/test/metrics.test.ts new file mode 100644 index 000000000000..5aebf2a64818 --- /dev/null +++ b/packages/effect/test/metrics.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from '@effect/vitest'; +import * as sentryCore from '@sentry/core'; +import { Duration, Effect, Metric, MetricBoundaries, MetricLabel } from 'effect'; +import { afterEach, beforeEach, vi } from 'vitest'; +import { createMetricsFlusher } from '../src/metrics'; + +describe('SentryEffectMetricsLayer', () => { + const mockCount = vi.fn(); + const mockGauge = vi.fn(); + const mockDistribution = vi.fn(); + + beforeEach(() => { + vi.spyOn(sentryCore.metrics, 'count').mockImplementation(mockCount); + vi.spyOn(sentryCore.metrics, 'gauge').mockImplementation(mockGauge); + vi.spyOn(sentryCore.metrics, 'distribution').mockImplementation(mockDistribution); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it.effect('creates counter metrics', () => + Effect.gen(function* () { + const counter = Metric.counter('test_counter'); + + yield* Metric.increment(counter); + yield* Metric.increment(counter); + yield* Metric.incrementBy(counter, 5); + + const snapshot = Metric.unsafeSnapshot(); + const counterMetric = snapshot.find(p => p.metricKey.name === 'test_counter'); + + expect(counterMetric).toBeDefined(); + }), + ); + + it.effect('creates gauge metrics', () => + Effect.gen(function* () { + const gauge = Metric.gauge('test_gauge'); + + yield* Metric.set(gauge, 42); + + const snapshot = Metric.unsafeSnapshot(); + const gaugeMetric = snapshot.find(p => p.metricKey.name === 'test_gauge'); + + expect(gaugeMetric).toBeDefined(); + }), + ); + + it.effect('creates histogram metrics', () => + Effect.gen(function* () { + const histogram = Metric.histogram('test_histogram', MetricBoundaries.linear({ start: 0, width: 10, count: 10 })); + + yield* Metric.update(histogram, 5); + yield* Metric.update(histogram, 15); + yield* Metric.update(histogram, 25); + + const snapshot = Metric.unsafeSnapshot(); + const histogramMetric = snapshot.find(p => p.metricKey.name === 'test_histogram'); + + expect(histogramMetric).toBeDefined(); + }), + ); + + it.effect('creates summary metrics', () => + Effect.gen(function* () { + const summary = Metric.summary({ + name: 'test_summary', + maxAge: '1 minute', + maxSize: 100, + error: 0.01, + quantiles: [0.5, 0.9, 0.99], + }); + + yield* Metric.update(summary, 10); + yield* Metric.update(summary, 20); + yield* Metric.update(summary, 30); + + const snapshot = Metric.unsafeSnapshot(); + const summaryMetric = snapshot.find(p => p.metricKey.name === 'test_summary'); + + expect(summaryMetric).toBeDefined(); + }), + ); + + it.effect('creates frequency metrics', () => + Effect.gen(function* () { + const frequency = Metric.frequency('test_frequency'); + + yield* Metric.update(frequency, 'foo'); + yield* Metric.update(frequency, 'bar'); + yield* Metric.update(frequency, 'foo'); + + const snapshot = Metric.unsafeSnapshot(); + const frequencyMetric = snapshot.find(p => p.metricKey.name === 'test_frequency'); + + expect(frequencyMetric).toBeDefined(); + }), + ); + + it.effect('supports metrics with labels', () => + Effect.gen(function* () { + const counter = Metric.counter('labeled_counter').pipe( + Metric.taggedWithLabels([MetricLabel.make('env', 'test'), MetricLabel.make('service', 'my-service')]), + ); + + yield* Metric.increment(counter); + + const snapshot = Metric.unsafeSnapshot(); + const labeledMetric = snapshot.find(p => p.metricKey.name === 'labeled_counter'); + + expect(labeledMetric).toBeDefined(); + const tags = labeledMetric?.metricKey.tags ?? []; + expect(tags.some(t => t.key === 'env' && t.value === 'test')).toBe(true); + expect(tags.some(t => t.key === 'service' && t.value === 'my-service')).toBe(true); + }), + ); + + it.effect('tracks Effect durations with timer metric', () => + Effect.gen(function* () { + const timer = Metric.timerWithBoundaries('operation_duration', [10, 50, 100, 500, 1000]); + + yield* Effect.succeed('done').pipe(Metric.trackDuration(timer)); + + const snapshot = Metric.unsafeSnapshot(); + const timerMetric = snapshot.find(p => p.metricKey.name === 'operation_duration'); + + expect(timerMetric).toBeDefined(); + }), + ); + + it.effect('integrates with Effect.timed', () => + Effect.gen(function* () { + const [duration, result] = yield* Effect.timed(Effect.succeed('completed')); + + expect(result).toBe('completed'); + expect(Duration.toMillis(duration)).toBeGreaterThanOrEqual(0); + }), + ); +}); + +describe('createMetricsFlusher', () => { + const mockCount = vi.fn(); + const mockGauge = vi.fn(); + const mockDistribution = vi.fn(); + + beforeEach(() => { + vi.spyOn(sentryCore.metrics, 'count').mockImplementation(mockCount); + vi.spyOn(sentryCore.metrics, 'gauge').mockImplementation(mockGauge); + vi.spyOn(sentryCore.metrics, 'distribution').mockImplementation(mockDistribution); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it.effect('sends counter metrics to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const counter = Metric.counter('flush_test_counter'); + + yield* Metric.increment(counter); + yield* Metric.incrementBy(counter, 4); + + flusher.flush(); + + expect(mockCount).toHaveBeenCalledWith('flush_test_counter', 5, { attributes: {} }); + }), + ); + + it.effect('sends gauge metrics to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const gauge = Metric.gauge('flush_test_gauge'); + + yield* Metric.set(gauge, 42); + + flusher.flush(); + + expect(mockGauge).toHaveBeenCalledWith('flush_test_gauge', 42, { attributes: {} }); + }), + ); + + it.effect('sends histogram metrics to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const histogram = Metric.histogram( + 'flush_test_histogram', + MetricBoundaries.linear({ start: 0, width: 10, count: 5 }), + ); + + yield* Metric.update(histogram, 5); + yield* Metric.update(histogram, 15); + + flusher.flush(); + + expect(mockDistribution).toHaveBeenCalledWith('flush_test_histogram.sum', expect.any(Number), { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.count', expect.any(Number), { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.min', expect.any(Number), { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.max', expect.any(Number), { attributes: {} }); + }), + ); + + it.effect('sends summary metrics to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const summary = Metric.summary({ + name: 'flush_test_summary', + maxAge: '1 minute', + maxSize: 100, + error: 0.01, + quantiles: [0.5, 0.9, 0.99], + }); + + yield* Metric.update(summary, 10); + yield* Metric.update(summary, 20); + yield* Metric.update(summary, 30); + + flusher.flush(); + + expect(mockDistribution).toHaveBeenCalledWith('flush_test_summary.sum', 60, { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.count', 3, { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.min', 10, { attributes: {} }); + expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.max', 30, { attributes: {} }); + }), + ); + + it.effect('sends frequency metrics to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const frequency = Metric.frequency('flush_test_frequency'); + + yield* Metric.update(frequency, 'apple'); + yield* Metric.update(frequency, 'banana'); + yield* Metric.update(frequency, 'apple'); + + flusher.flush(); + + expect(mockCount).toHaveBeenCalledWith('flush_test_frequency', 2, { attributes: { word: 'apple' } }); + expect(mockCount).toHaveBeenCalledWith('flush_test_frequency', 1, { attributes: { word: 'banana' } }); + }), + ); + + it.effect('sends metrics with labels as attributes to Sentry', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const gauge = Metric.gauge('flush_test_labeled_gauge').pipe( + Metric.taggedWithLabels([MetricLabel.make('env', 'production'), MetricLabel.make('region', 'us-east')]), + ); + + yield* Metric.set(gauge, 100); + + flusher.flush(); + + expect(mockGauge).toHaveBeenCalledWith('flush_test_labeled_gauge', 100, { + attributes: { env: 'production', region: 'us-east' }, + }); + }), + ); + + it.effect('sends counter delta values on subsequent flushes', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const counter = Metric.counter('flush_test_delta_counter'); + + yield* Metric.incrementBy(counter, 10); + flusher.flush(); + + mockCount.mockClear(); + + yield* Metric.incrementBy(counter, 5); + flusher.flush(); + + expect(mockCount).toHaveBeenCalledWith('flush_test_delta_counter', 5, { attributes: {} }); + }), + ); + + it.effect('does not send counter when delta is zero', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const counter = Metric.counter('flush_test_zero_delta'); + + yield* Metric.incrementBy(counter, 10); + flusher.flush(); + + mockCount.mockClear(); + + flusher.flush(); + + expect(mockCount).not.toHaveBeenCalledWith('flush_test_zero_delta', 0, { attributes: {} }); + }), + ); + + it.effect('clear() resets delta tracking state', () => + Effect.gen(function* () { + const flusher = createMetricsFlusher(); + const counter = Metric.counter('flush_test_clear_counter'); + + yield* Metric.incrementBy(counter, 10); + flusher.flush(); + + mockCount.mockClear(); + flusher.clear(); + + flusher.flush(); + + expect(mockCount).toHaveBeenCalledWith('flush_test_clear_counter', 10, { attributes: {} }); + }), + ); + + it('each flusher has isolated state', () => { + const flusher1 = createMetricsFlusher(); + const flusher2 = createMetricsFlusher(); + + expect(flusher1).not.toBe(flusher2); + expect(flusher1.flush).not.toBe(flusher2.flush); + expect(flusher1.clear).not.toBe(flusher2.clear); + }); +});