-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(effect): Add metrics to Sentry.effectLayer #19709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: jp/add-effect-sdk
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>; | ||
|
|
||
| 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 }, | ||
| }); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Frequency metrics double-counted on every flush cycleHigh Severity Frequency metrics use Additional Locations (1) |
||
| } | ||
|
|
||
| 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<string, number>, | ||
| ): 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<string, number>): 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<string, number>(); | ||
| return { | ||
| flush: () => flushMetricsToSentry(previousCounterValues), | ||
| clear: () => previousCounterValues.clear(), | ||
| }; | ||
| } | ||
|
|
||
| function createMetricsReporterEffect(previousCounterValues: Map<string, number>): Effect.Effect<void, never, never> { | ||
| 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<never, never, never> = scopedDiscard( | ||
| Effect.gen(function* () { | ||
| const previousCounterValues = new Map<string, number>(); | ||
|
|
||
| yield* Effect.acquireRelease(Effect.void, () => | ||
| Effect.sync(() => { | ||
| previousCounterValues.clear(); | ||
| }), | ||
| ); | ||
|
|
||
| yield* Effect.forkScoped(createMetricsReporterEffect(previousCounterValues)); | ||
| }), | ||
| ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No final metrics flush before layer teardownMedium Severity The |
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Histogram/summary cumulative sum re-emitted as new distribution points
Medium Severity
For histogram and summary states,
metricState.sum(a cumulative value) is emitted viasentryMetrics.distribution()on each flush. Sincedistributionrecords a new data point per call, the cumulative sum gets repeatedly added as a fresh observation every 10 seconds, producing incorrect distribution data in Sentry. This likely needs to be agaugeor use delta tracking.Additional Locations (1)
packages/effect/src/metrics.ts#L33-L34