solarwinds-apm
Version:
OpenTelemetry-based SolarWinds APM library
273 lines (248 loc) • 7.41 kB
text/typescript
/*
Copyright 2023-2025 SolarWinds Worldwide, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import util from "node:util"
import { type Attributes } from "@opentelemetry/api"
import {
type ExportResult,
ExportResultCode,
type InstrumentationScope,
} from "@opentelemetry/core"
import {
AggregationTemporality,
DataPointType,
type ExponentialHistogram as ExponentialHistogramDatapoint,
type Histogram as HistogramDatapoint,
InstrumentType,
type MetricDescriptor,
type PushMetricExporter,
type ResourceMetrics,
} from "@opentelemetry/sdk-metrics"
import { oboe } from "@solarwinds-apm/bindings"
import {
explicit,
exponential,
type Histogram,
} from "@solarwinds-apm/histogram"
import { componentLogger } from "../../shared/logger.js"
const MAX_TAGS = 50
export class AppopticsMetricExporter implements PushMetricExporter {
readonly #logger = componentLogger(AppopticsMetricExporter)
readonly #reporter: oboe.Reporter
constructor(reporter: oboe.Reporter) {
this.#reporter = reporter
}
export(
metrics: ResourceMetrics,
resultCallback: (result: ExportResult) => void,
): void {
for (const scopeMetric of metrics.scopeMetrics) {
const scopeTags = this.#scopeTags(scopeMetric.scope)
for (const metric of scopeMetric.metrics) {
const name = metric.descriptor.name
const temporality = metric.aggregationTemporality
const descriptorTags = this.#descriptorTags(metric.descriptor)
for (const dataPoint of metric.dataPoints) {
const dataPointTags = this.#dataPointTags(dataPoint.attributes)
const [tags, tagCount] = this.#oboeTags({
...scopeTags,
...descriptorTags,
...dataPointTags,
})
switch (metric.dataPointType) {
case DataPointType.SUM: {
if (temporality === AggregationTemporality.DELTA) {
this.#exportCounter(
dataPoint.value as number,
name,
tags,
tagCount,
)
} else {
this.#exportSummary(
dataPoint.value as number,
name,
tags,
tagCount,
)
}
break
}
case DataPointType.GAUGE: {
if (temporality === AggregationTemporality.CUMULATIVE) {
this.#exportSummary(
dataPoint.value as number,
name,
tags,
tagCount,
)
} else {
this.#logger.warn(
"gauges with delta aggregation are not supported",
)
}
break
}
case DataPointType.HISTOGRAM: {
if (temporality === AggregationTemporality.DELTA) {
const histogram = explicit(
dataPoint.value as HistogramDatapoint,
)
this.#exportHistogram(histogram, name, tags, tagCount)
} else {
this.#logger.warn(
"histograms with cumulative aggregation are not supported",
)
}
break
}
case DataPointType.EXPONENTIAL_HISTOGRAM: {
if (temporality === AggregationTemporality.DELTA) {
const histogram = exponential(
dataPoint.value as ExponentialHistogramDatapoint,
)
this.#exportHistogram(histogram, name, tags, tagCount)
} else {
this.#logger.warn(
"histograms with cumulative aggregation are not supported",
)
}
break
}
}
}
}
}
resultCallback({
code: ExportResultCode.SUCCESS,
})
}
selectAggregationTemporality(
instrumentType: InstrumentType,
): AggregationTemporality {
switch (instrumentType) {
case InstrumentType.HISTOGRAM: {
return AggregationTemporality.DELTA
}
default: {
return AggregationTemporality.CUMULATIVE
}
}
}
forceFlush(): Promise<void> {
this.#reporter.flush()
return Promise.resolve()
}
shutdown(): Promise<void> {
oboe.Context.shutdown()
return Promise.resolve()
}
#exportCounter(
value: number,
name: string,
tags: oboe.MetricTags,
tagCount: number,
) {
oboe.CustomMetrics.increment({
name,
count: value,
tags,
tags_count: tagCount,
host_tag: 1,
service_name: null,
})
}
#exportSummary(
value: number,
name: string,
tags: oboe.MetricTags,
tagCount: number,
) {
oboe.CustomMetrics.summary({
name,
value,
count: 1,
tags,
tags_count: tagCount,
host_tag: 1,
service_name: null,
})
}
#exportHistogram(
histogram: Histogram,
name: string,
tags: oboe.MetricTags,
tagCount: number,
) {
const stats = {
count: histogram.count,
min: histogram.min,
max: histogram.max,
p50: histogram.getPercentile(50),
p95: histogram.getPercentile(95),
p99: histogram.getPercentile(99),
}
const filtered = Object.entries(stats).filter(([, v]) => v !== undefined)
for (const [stat, value] of filtered) {
oboe.CustomMetrics.summary({
name: `${name}.${stat}`,
value: value!,
count: 1,
tags,
tags_count: tagCount,
host_tag: 1,
service_name: null,
})
}
}
#oboeTags(
tags: Record<string, string>,
): [tags: oboe.MetricTags, count: number] {
const entries = Object.entries(tags)
const count = Math.min(entries.length, MAX_TAGS)
entries.length = count
const oboeTags = new oboe.MetricTags(count)
for (const [i, [k, v]] of entries.entries()) {
oboeTags.add(i, k, v)
}
return [oboeTags, count]
}
#scopeTags(scope: InstrumentationScope): Record<string, string> {
const tags: Record<string, string> = { "scope.name": scope.name }
if (scope.version) {
tags["scope.version"] = scope.version
}
return tags
}
#descriptorTags(descriptor: MetricDescriptor): Record<string, string> {
return {
description: descriptor.description,
unit: descriptor.unit,
}
}
#dataPointTags(attributes: Attributes): Record<string, string> {
return Object.fromEntries(
Object.entries(attributes)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => {
if (Array.isArray(v)) {
return [
k,
util.inspect(v, { breakLength: Infinity, compact: true }),
]
} else {
return [k, v!.toString()]
}
}),
)
}
}