| /** | |
| * Exports Effect metrics over OTLP/HTTP. | |
| * | |
| * This module periodically snapshots metrics from the current Effect context, | |
| * serializes them as OTLP resource metrics, and posts them to a metrics | |
| * endpoint such as an OpenTelemetry Collector or vendor intake. It is meant for | |
| * long-running services that already update `Metric` counters, gauges, | |
| * histograms, frequencies, or summaries. The exporter supports cumulative | |
| * reporting from a fixed start time and delta reporting from the previous | |
| * export. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| import * as Arr from "../../Array.js"; | |
| import { Clock } from "../../Clock.js"; | |
| import * as Config from "../../Config.js"; | |
| import * as Duration from "../../Duration.js"; | |
| import * as Effect from "../../Effect.js"; | |
| import * as Layer from "../../Layer.js"; | |
| import * as Metric from "../../Metric.js"; | |
| import * as Option from "../../Option.js"; | |
| import * as OtlpEnv from "./internal/otlpEnv.js"; | |
| import * as Exporter from "./OtlpExporter.js"; | |
| import * as OtlpResource from "./OtlpResource.js"; | |
| import { OtlpSerialization } from "./OtlpSerialization.js"; | |
| /** | |
| * Starts a scoped OTLP metrics exporter. | |
| * | |
| * **Details** | |
| * | |
| * The exporter snapshots registered Effect metrics on the configured interval, serializes them with the selected aggregation temporality, and flushes during scope finalization up to `shutdownTimeout`. | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) { | |
| const clock = yield* Clock; | |
| const serialization = yield* OtlpSerialization; | |
| const startTimeNanos = yield* clock.currentTimeNanos; | |
| const startTime = String(startTimeNanos); | |
| const temporality = options.temporality ?? "cumulative"; | |
| const resource = yield* OtlpResource.fromConfig(options.resource); | |
| const metricsScope = { | |
| name: OtlpResource.serviceNameUnsafe(resource) | |
| }; | |
| const services = yield* Effect.context(); | |
| // State for delta temporality tracking | |
| let previousExportTimeNanos = startTimeNanos; | |
| const previousCounterState = new Map(); | |
| const previousHistogramState = new Map(); | |
| const previousFrequencyState = new Map(); | |
| const previousSummaryState = new Map(); | |
| const snapshot = () => { | |
| const snapshot = Metric.snapshotUnsafe(services); | |
| const nowNanos = clock.currentTimeNanosUnsafe(); | |
| const nowTime = String(nowNanos); | |
| const metricData = []; | |
| const metricDataByName = new Map(); | |
| const addMetricData = data => { | |
| metricData.push(data); | |
| metricDataByName.set(data.name, data); | |
| }; | |
| const isDelta = temporality === "delta"; | |
| const aggregationTemporalityEnum = isDelta ? EAggregationTemporality.AGGREGATION_TEMPORALITY_DELTA : EAggregationTemporality.AGGREGATION_TEMPORALITY_CUMULATIVE; | |
| const intervalStartTime = isDelta ? String(previousExportTimeNanos) : startTime; | |
| for (let i = 0, len = snapshot.length; i < len; i++) { | |
| const state = snapshot[i]; | |
| const unit = state.attributes?.unit ?? state.attributes?.time_unit ?? "1"; | |
| const attributes = state.attributes ? OtlpResource.entriesToAttributes(Object.entries(state.attributes)) : []; | |
| const metricKey = makeMetricKey(state.id, state.attributes); | |
| switch (state.type) { | |
| case "Counter": | |
| { | |
| const currentCount = state.state.count; | |
| let reportValue = currentCount; | |
| if (isDelta) { | |
| const previousCount = previousCounterState.get(metricKey); | |
| if (previousCount !== undefined) { | |
| if (typeof currentCount === "bigint" && typeof previousCount === "bigint") { | |
| reportValue = currentCount - previousCount; | |
| // Handle reset: if current < previous, report current value | |
| if (reportValue < BigInt(0)) { | |
| reportValue = currentCount; | |
| } | |
| } else { | |
| const curr = Number(currentCount); | |
| const prev = Number(previousCount); | |
| reportValue = curr - prev; | |
| // Handle reset | |
| if (reportValue < 0) { | |
| reportValue = curr; | |
| } | |
| } | |
| } | |
| previousCounterState.set(metricKey, currentCount); | |
| } | |
| const dataPoint = { | |
| attributes, | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime | |
| }; | |
| if (typeof reportValue === "bigint") { | |
| dataPoint.asInt = Number(reportValue); | |
| } else { | |
| dataPoint.asDouble = reportValue; | |
| } | |
| if (metricDataByName.has(state.id)) { | |
| metricDataByName.get(state.id).sum.dataPoints.push(dataPoint); | |
| } else { | |
| addMetricData({ | |
| name: state.id, | |
| description: state.description, | |
| unit, | |
| sum: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| isMonotonic: state.state.incremental, | |
| dataPoints: [dataPoint] | |
| } | |
| }); | |
| } | |
| break; | |
| } | |
| case "Gauge": | |
| { | |
| // Gauges don't have temporality - they always report current value | |
| const dataPoint = { | |
| attributes, | |
| startTimeUnixNano: startTime, | |
| timeUnixNano: nowTime | |
| }; | |
| if (typeof state.state.value === "bigint") { | |
| dataPoint.asInt = Number(state.state.value); | |
| } else { | |
| dataPoint.asDouble = state.state.value; | |
| } | |
| if (metricDataByName.has(state.id)) { | |
| metricDataByName.get(state.id).gauge.dataPoints.push(dataPoint); | |
| } else { | |
| addMetricData({ | |
| name: state.id, | |
| description: state.description, | |
| unit, | |
| gauge: { | |
| dataPoints: [dataPoint] | |
| } | |
| }); | |
| } | |
| break; | |
| } | |
| case "Histogram": | |
| { | |
| const size = state.state.buckets.length; | |
| const currentBuckets = { | |
| boundaries: Arr.allocate(size - 1), | |
| counts: Arr.allocate(size) | |
| }; | |
| let idx = 0; | |
| let prev = 0; | |
| for (const [boundary, value] of state.state.buckets) { | |
| if (idx < size - 1) { | |
| currentBuckets.boundaries[idx] = boundary; | |
| } | |
| currentBuckets.counts[idx] = value - prev; | |
| prev = value; | |
| idx++; | |
| } | |
| let reportCount = state.state.count; | |
| let reportSum = state.state.sum; | |
| let reportBucketCounts = currentBuckets.counts; | |
| const reportMin = state.state.min; | |
| const reportMax = state.state.max; | |
| if (isDelta) { | |
| const previousState = previousHistogramState.get(metricKey); | |
| if (previousState !== undefined) { | |
| reportCount = state.state.count - previousState.count; | |
| reportSum = state.state.sum - previousState.sum; | |
| reportBucketCounts = currentBuckets.counts.map((c, i) => Math.max(0, c - (previousState.bucketCounts[i] ?? 0))); | |
| // For delta, min/max represent the interval's min/max | |
| // We can't compute these from cumulative state, so we use current values | |
| // Note: This is a limitation - true delta min/max would require tracking | |
| // observations within each interval | |
| } | |
| previousHistogramState.set(metricKey, { | |
| count: state.state.count, | |
| sum: state.state.sum, | |
| bucketCounts: currentBuckets.counts.slice(), | |
| min: state.state.min, | |
| max: state.state.max | |
| }); | |
| } | |
| const dataPoint = { | |
| attributes, | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| count: reportCount, | |
| min: reportMin, | |
| max: reportMax, | |
| sum: reportSum, | |
| bucketCounts: reportBucketCounts, | |
| explicitBounds: currentBuckets.boundaries | |
| }; | |
| if (metricDataByName.has(state.id)) { | |
| metricDataByName.get(state.id).histogram.dataPoints.push(dataPoint); | |
| } else { | |
| addMetricData({ | |
| name: state.id, | |
| description: state.description, | |
| unit, | |
| histogram: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| dataPoints: [dataPoint] | |
| } | |
| }); | |
| } | |
| break; | |
| } | |
| case "Frequency": | |
| { | |
| const dataPoints = []; | |
| const currentOccurrences = new Map(); | |
| for (const [freqKey, value] of state.state.occurrences) { | |
| currentOccurrences.set(freqKey, value); | |
| let reportValue = value; | |
| if (isDelta) { | |
| const previousOccurrences = previousFrequencyState.get(metricKey); | |
| if (previousOccurrences !== undefined) { | |
| const previousValue = previousOccurrences.get(freqKey) ?? 0; | |
| reportValue = Math.max(0, value - previousValue); | |
| } | |
| } | |
| dataPoints.push({ | |
| attributes: [...attributes, { | |
| key: "key", | |
| value: { | |
| stringValue: freqKey | |
| } | |
| }], | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asInt: reportValue | |
| }); | |
| } | |
| if (isDelta) { | |
| previousFrequencyState.set(metricKey, currentOccurrences); | |
| } | |
| if (metricDataByName.has(state.id)) { | |
| metricDataByName.get(state.id).sum.dataPoints.push(...dataPoints); | |
| } else { | |
| addMetricData({ | |
| name: state.id, | |
| description: state.description, | |
| unit, | |
| sum: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| isMonotonic: true, | |
| dataPoints | |
| } | |
| }); | |
| } | |
| break; | |
| } | |
| case "Summary": | |
| { | |
| // Quantiles are always computed fresh from the sliding window | |
| // They don't have temporality in the traditional sense | |
| const dataPoints = [{ | |
| attributes: [...attributes, { | |
| key: "quantile", | |
| value: { | |
| stringValue: "min" | |
| } | |
| }], | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asDouble: state.state.min | |
| }]; | |
| for (const [quantile, value] of state.state.quantiles) { | |
| dataPoints.push({ | |
| attributes: [...attributes, { | |
| key: "quantile", | |
| value: { | |
| stringValue: quantile.toString() | |
| } | |
| }], | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asDouble: value ?? 0 | |
| }); | |
| } | |
| dataPoints.push({ | |
| attributes: [...attributes, { | |
| key: "quantile", | |
| value: { | |
| stringValue: "max" | |
| } | |
| }], | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asDouble: state.state.max | |
| }); | |
| let reportCount = state.state.count; | |
| let reportSum = state.state.sum; | |
| if (isDelta) { | |
| const previousState = previousSummaryState.get(metricKey); | |
| if (previousState !== undefined) { | |
| reportCount = state.state.count - previousState.count; | |
| reportSum = state.state.sum - previousState.sum; | |
| } | |
| previousSummaryState.set(metricKey, { | |
| count: state.state.count, | |
| sum: state.state.sum | |
| }); | |
| } | |
| const countDataPoint = { | |
| attributes, | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asInt: reportCount | |
| }; | |
| const sumDataPoint = { | |
| attributes, | |
| startTimeUnixNano: intervalStartTime, | |
| timeUnixNano: nowTime, | |
| asDouble: reportSum | |
| }; | |
| if (metricDataByName.has(`${state.id}_quantiles`)) { | |
| metricDataByName.get(`${state.id}_quantiles`).sum.dataPoints.push(...dataPoints); | |
| metricDataByName.get(`${state.id}_count`).sum.dataPoints.push(countDataPoint); | |
| metricDataByName.get(`${state.id}_sum`).sum.dataPoints.push(sumDataPoint); | |
| } else { | |
| addMetricData({ | |
| name: `${state.id}_quantiles`, | |
| description: state.description, | |
| unit, | |
| sum: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| isMonotonic: false, | |
| dataPoints | |
| } | |
| }); | |
| addMetricData({ | |
| name: `${state.id}_count`, | |
| description: state.description, | |
| unit: "1", | |
| sum: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| isMonotonic: true, | |
| dataPoints: [countDataPoint] | |
| } | |
| }); | |
| addMetricData({ | |
| name: `${state.id}_sum`, | |
| description: state.description, | |
| unit: "1", | |
| sum: { | |
| aggregationTemporality: aggregationTemporalityEnum, | |
| isMonotonic: true, | |
| dataPoints: [sumDataPoint] | |
| } | |
| }); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| // Update the previous export time for delta calculations | |
| if (isDelta) { | |
| previousExportTimeNanos = nowNanos; | |
| } | |
| return serialization.metrics({ | |
| resourceMetrics: [{ | |
| resource, | |
| scopeMetrics: [{ | |
| scope: metricsScope, | |
| metrics: metricData | |
| }] | |
| }] | |
| }); | |
| }; | |
| yield* Exporter.make({ | |
| label: "OtlpMetrics", | |
| url: options.url, | |
| headers: options.headers, | |
| maxBatchSize: "disabled", | |
| exportInterval: options.exportInterval ?? Duration.seconds(10), | |
| body: snapshot, | |
| shutdownTimeout: options.shutdownTimeout ?? Duration.seconds(3) | |
| }); | |
| }); | |
| /** | |
| * Layer that starts the OTLP metrics exporter created by `make`. | |
| * | |
| * @category layers | |
| * @since 4.0.0 | |
| */ | |
| export const layer = options => Layer.effectDiscard(make(options)); | |
| /** | |
| * Creates an OTLP metrics layer from OpenTelemetry configuration. | |
| * | |
| * @category layers | |
| * @since 4.0.0 | |
| */ | |
| export const layerFromConfig = options => Effect.gen(function* () { | |
| const { | |
| disabled, | |
| endpoint, | |
| exporters | |
| } = yield* Config.all({ | |
| disabled: Config.boolean("OTEL_SDK_DISABLED").pipe(Config.withDefault(false)), | |
| endpoint: OtlpEnv.endpoint("METRICS"), | |
| exporters: OtlpEnv.exporters("METRICS") | |
| }); | |
| if (disabled || !endpoint || !exporters.includes("otlp")) { | |
| return Layer.empty; | |
| } | |
| const { | |
| baseTimeout, | |
| metricsTimeout, | |
| exportTimeout, | |
| exportInterval, | |
| temporalityPreference | |
| } = yield* Config.all({ | |
| baseTimeout: Config.option(Config.int("OTEL_EXPORTER_OTLP_TIMEOUT")), | |
| metricsTimeout: Config.option(Config.int("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT")), | |
| exportTimeout: Config.option(Config.int("OTEL_METRIC_EXPORT_TIMEOUT")), | |
| exportInterval: Config.option(Config.int("OTEL_METRIC_EXPORT_INTERVAL").pipe(Config.map(Duration.millis))), | |
| temporalityPreference: Config.option(Config.literals(["delta", "cumulative"], "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE")) | |
| }); | |
| const shutdownTimeout = Option.firstSomeOf([metricsTimeout, baseTimeout, exportTimeout]).pipe(Option.map(_ => Duration.millis(_))); | |
| return layer({ | |
| url: endpoint.toString(), | |
| resource: options?.resource, | |
| headers: options?.headers ?? (yield* OtlpEnv.headers("METRICS")), | |
| exportInterval: Option.getOrUndefined(exportInterval), | |
| shutdownTimeout: Option.getOrUndefined(shutdownTimeout), | |
| temporality: Option.getOrUndefined(temporalityPreference) | |
| }); | |
| }).pipe(Effect.orDie, Layer.unwrap); | |
| // internal | |
| /** Creates a unique key for a metric including its attributes */ | |
| const makeMetricKey = (id, attributes) => { | |
| if (attributes === undefined || Object.keys(attributes).length === 0) { | |
| return id; | |
| } | |
| const sortedEntries = Object.entries(attributes).sort((a, b) => a[0].localeCompare(b[0])); | |
| return `${id}:${JSON.stringify(sortedEntries)}`; | |
| }; | |
| /** | |
| * AggregationTemporality defines how a metric aggregator reports aggregated | |
| * values. It describes how those values relate to the time interval over | |
| * which they are aggregated. | |
| */ | |
| const EAggregationTemporality = { | |
| AGGREGATION_TEMPORALITY_UNSPECIFIED: 0, | |
| /** DELTA is an AggregationTemporality for a metric aggregator which reports | |
| changes since last report time. Successive metrics contain aggregation of | |
| values from continuous and non-overlapping intervals. | |
| The values for a DELTA metric are based only on the time interval | |
| associated with one measurement cycle. There is no dependency on | |
| previous measurements like is the case for CUMULATIVE metrics. | |
| For example, consider a system measuring the number of requests that | |
| it receives and reports the sum of these requests every second as a | |
| DELTA metric: | |
| 1. The system starts receiving at time=t_0. | |
| 2. A request is received, the system measures 1 request. | |
| 3. A request is received, the system measures 1 request. | |
| 4. A request is received, the system measures 1 request. | |
| 5. The 1 second collection cycle ends. A metric is exported for the | |
| number of requests received over the interval of time t_0 to | |
| t_0+1 with a value of 3. | |
| 6. A request is received, the system measures 1 request. | |
| 7. A request is received, the system measures 1 request. | |
| 8. The 1 second collection cycle ends. A metric is exported for the | |
| number of requests received over the interval of time t_0+1 to | |
| t_0+2 with a value of 2. */ | |
| AGGREGATION_TEMPORALITY_DELTA: 1, | |
| /** CUMULATIVE is an AggregationTemporality for a metric aggregator which | |
| reports changes since a fixed start time. This means that current values | |
| of a CUMULATIVE metric depend on all previous measurements since the | |
| start time. Because of this, the sender is required to retain this state | |
| in some form. If this state is lost or invalidated, the CUMULATIVE metric | |
| values MUST be reset and a new fixed start time following the last | |
| reported measurement time sent MUST be used. | |
| For example, consider a system measuring the number of requests that | |
| it receives and reports the sum of these requests every second as a | |
| CUMULATIVE metric: | |
| 1. The system starts receiving at time=t_0. | |
| 2. A request is received, the system measures 1 request. | |
| 3. A request is received, the system measures 1 request. | |
| 4. A request is received, the system measures 1 request. | |
| 5. The 1 second collection cycle ends. A metric is exported for the | |
| number of requests received over the interval of time t_0 to | |
| t_0+1 with a value of 3. | |
| 6. A request is received, the system measures 1 request. | |
| 7. A request is received, the system measures 1 request. | |
| 8. The 1 second collection cycle ends. A metric is exported for the | |
| number of requests received over the interval of time t_0 to | |
| t_0+2 with a value of 5. | |
| 9. The system experiences a fault and loses state. | |
| 10. The system recovers and resumes receiving at time=t_1. | |
| 11. A request is received, the system measures 1 request. | |
| 12. The 1 second collection cycle ends. A metric is exported for the | |
| number of requests received over the interval of time t_1 to | |
| t_0+1 with a value of 1. | |
| Note: Even though, when reporting changes since last report time, using | |
| CUMULATIVE is valid, it is not recommended. This may cause problems for | |
| systems that do not use start_time to determine when the aggregation | |
| value was reset (e.g. Prometheus). */ | |
| AGGREGATION_TEMPORALITY_CUMULATIVE: 2 | |
| }; | |
| //# sourceMappingURL=OtlpMetrics.js.map |
Xet Storage Details
- Size:
- 20.8 kB
- Xet hash:
- d33e9f73eaed059a37a4928e3d503709ae0d3a770851c8c702f9ded094fb6a57
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.