From 6767be56d2a71d9c79fcbd13f92894ef25f2446a Mon Sep 17 00:00:00 2001 From: Arya Date: Mon, 3 Aug 2026 10:53:32 +0530 Subject: [PATCH 1/3] feat(otlp-exporter): added support for runtime metrics --- .../src/otlpExporter/LOGS_SIGNAL_RESEARCH.md | 417 ++++++++++++++++++ .../common/semconv/base/mappings.js | 22 + .../src/otlpExporter/metrics/converter.js | 4 +- .../otlpExporter/metrics/mappers/constants.js | 17 + .../src/otlpExporter/metrics/mappers/index.js | 18 + .../metrics/mappers/runtimeMetrics.js | 163 +++++++ .../metrics/transformers/index.js | 5 +- .../metrics/transformers/runtimeMetrics.js | 46 ++ .../metrics/fixtures/input/metrics.json | 1 + .../fixtures/output/metrics-output.json | 73 ++- .../metrics/mappers/runtimeMetrics_test.js | 237 ++++++++++ .../transformers/runtimeMetrics_test.js | 78 ++++ 12 files changed, 1077 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md create mode 100644 packages/core/src/otlpExporter/metrics/mappers/constants.js create mode 100644 packages/core/src/otlpExporter/metrics/mappers/index.js create mode 100644 packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js create mode 100644 packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js create mode 100644 packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js create mode 100644 packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js diff --git a/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md b/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md new file mode 100644 index 0000000000..c7bfba0add --- /dev/null +++ b/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md @@ -0,0 +1,417 @@ +# OTLP Logs Signal — Research & Mapping Document + +> **Author:** Research for `feat-otlp-exporter` +> **Scope:** `packages/core/src/otlpExporter/` +> **Status:** Draft — pre-implementation + +--- + +## 1. Context + +The OTLP exporter currently handles two signals: + +| Signal | Entry point | Status | +|---------|--------------------------------------------|-------------| +| Traces | `traces/converter.js` → `convert(spans)` | ✅ Implemented | +| Metrics | `metrics/converter.js` → `convert(metrics)` | 🔧 Stub (Phase 2) | +| **Logs** | _(none — skipped in `traces/converter.js`)_ | 🚫 **TODO** | + +Log spans are currently skipped at the hot path in [`traces/converter.js`](./traces/converter.js): + +```js +if (isLogSpan(span)) { + // TODO: Add log span converter + continue; +} +``` + +Log spans are identified by [`traces/util.js#isLogSpan`](./traces/util.js): + +```js +function isLogSpan(span) { + if (!span) return false; + if (span.data && span.data.log) return true; + if (span.n && typeof span.n === 'string' && span.n.startsWith('log.')) return true; + return false; +} +``` + +--- + +## 2. Instana Log Span Structure + +All five logging instrumentations (pino, winston, bunyan, log4js, console) produce spans with **the same shape**. There are no extra fields beyond `message` and `level`. + +### 2.1 Shape + +```js +{ + // ── Span envelope ────────────────────────────────────────── + t: "abc123", // trace ID (hex string, 16 or 32 chars) + s: "def456", // span ID (hex string, 16 chars) + p: "789abc", // parent span ID (hex, optional) + n: "log.pino", // span name: one of the values below + k: 2, // kind: always EXIT (2) + ts: 1706000000000, // start timestamp (ms) + d: 0, // duration (ms) — always ~0 for log spans + ec: 0 | 1, // error count (1 for ERROR / FATAL) + f: { e: "12345", h: "host-id" }, // from / process identity + stack: [...], // stack trace array + + // ── Log-specific payload ──────────────────────────────────── + data: { + log: { + message: "Something went wrong", // string + level: "error" // normalized log level string (see §3) + } + } +} +``` + +### 2.2 Span name values per logger + +| Instrumentation file | `span.n` value | +|----------------------|----------------| +| `logging/pino.js` | `log.pino` | +| `logging/winston.js` | `log.winston` | +| `logging/bunyan.js` | `log.bunyan` | +| `logging/log4js.js` | `log.log4js` | +| `logging/console.js` | `log.console` | + +All start with `log.` — this is exactly what `isLogSpan()` checks via `span.n.startsWith('log.')`. + +### 2.3 Normalized log levels + +Defined in [`util/constants.js#LOG_LEVEL`](../util/constants.js): + +| String value | Priority | +|---|---| +| `trace` | 10 | +| `debug` | 20 | +| `info` | 30 | +| `warn` | 40 | +| `error` | 50 | +| `fatal` | 60 | + +`ec = 1` (error count) is set by `tracingUtil.isLogLevelAnError()` when `level === 'error'` or `level === 'fatal'`. + +Capture threshold is controlled by config `tracing.captureLogLevel` (default: `warn`), checked by `tracingUtil.shouldCaptureLogSpan()` before the span is even created — so by the time a log span reaches the OTLP exporter, it has already passed the filter. + +--- + +## 3. OTel Log Data Model Reference + +From [opentelemetry.io/docs/specs/semconv/general/logs](https://opentelemetry.io/docs/specs/semconv/general/logs/) +and [opentelemetry.io/docs/concepts/signals/logs](https://opentelemetry.io/docs/concepts/signals/logs/). + +### 3.1 OTLP Log Record top-level fields + +The OTLP `LogRecord` message (proto: `opentelemetry.proto.logs.v1.LogRecord`) has the following mandatory/optional fields: + +| OTLP field | Type | Description | +|--------------------------|------------|-------------| +| `timeUnixNano` | uint64 | Timestamp of the log event (nanoseconds since epoch) | +| `observedTimeUnixNano` | uint64 | When the log was observed / collected | +| `severityNumber` | enum (int) | Numeric severity 1–24 (see §3.2) | +| `severityText` | string | Human-readable severity string | +| `body` | AnyValue | The log message body | +| `attributes` | KeyValues | Structured key-value pairs | +| `traceId` | bytes/hex | W3C-compatible 16-byte trace ID | +| `spanId` | bytes/hex | 8-byte span ID | +| `traceFlags` | uint32 | W3C trace flags | +| `droppedAttributesCount` | uint32 | Count of dropped attributes | +| `eventName` | string | (OTel 1.41+) Optional event name | +| `flags` | uint32 | Log record flags | + +The OTLP **container** structure mirrors traces/metrics: + +```json +{ + "resourceLogs": [ + { + "resource": { "attributes": [...] }, + "scopeLogs": [ + { + "scope": { "name": "@instana/collector", "version": "..." }, + "logRecords": [ ...LogRecord ] + } + ] + } + ] +} +``` + +### 3.2 OTel Severity Number Mapping + +| OTel SeverityNumber | Range | Meaning | +|---------------------|-------|---------| +| 1–4 | TRACE, TRACE2–TRACE4 | Very low level trace | +| 5–8 | DEBUG, DEBUG2–DEBUG4 | Debug | +| 9–12 | INFO, INFO2–INFO4 | Informational | +| 13–16 | WARN, WARN2–WARN4 | Warning | +| 17–20 | ERROR, ERROR2–ERROR4 | Error | +| 21–24 | FATAL, FATAL2–FATAL4 | Fatal | + +The canonical single values per level (no suffix) are: 1, 5, 9, 13, 17, 21. + +### 3.3 Semantic Convention Attributes relevant to Logs + +From OTel semconv (general/logs + code conventions): + +| OTel attribute key | Description | +|----------------------|-------------| +| `log.record.uid` | A unique identifier for the log record | +| `log.iostream` | `stdout` or `stderr` (for console) | +| `code.function` | Function/method name that emitted the log | +| `code.filepath` | File path where log was emitted | +| `code.lineno` | Line number | +| `exception.type` | Exception class name (for error logs) | +| `exception.message` | Exception message | +| `exception.stacktrace` | Full exception stacktrace | + +--- + +## 4. Instana → OTLP Log Record Field Mapping + +### 4.1 Core Field Mapping Table + +| Instana field | OTLP LogRecord field | Transform / Notes | +|---|---|---| +| `span.ts` (ms) | `timeUnixNano` | `span.ts * 1_000_000` → nanoseconds string | +| `span.ts` (ms) | `observedTimeUnixNano` | Same as `timeUnixNano` — Instana does not distinguish observed vs occurred | +| `data.log.level` | `severityNumber` | Map via level→severity table (§4.2) | +| `data.log.level` | `severityText` | Uppercase: `"WARN"`, `"ERROR"`, etc. | +| `data.log.message` | `body` | `{ stringValue: message }` | +| `span.t` | `traceId` | Pad to 32 hex chars (same as trace converter) | +| `span.s` | `spanId` | Pad to 16 hex chars | +| `span.n` | `attributes["log.iostream"]` | `log.console` → infer `"stdout"` / `"stderr"` | +| `span.n` | `attributes["telemetry.sdk.name"]` | Can also go on resource (already done) | +| `span.stack[0]` | `attributes["code.function"]` | Frame function name if stack available | +| `span.stack[0]` | `attributes["code.filepath"]` | Frame file path if stack available | +| `span.stack[0]` | `attributes["code.lineno"]` | Frame line number if stack available | +| `span.ec` | _(drives `severityNumber`)_ | If `ec=1`, severity ≥ ERROR; used as cross-check | +| `span.p` | _(not mapped)_ | Parent span ID has no direct equivalent in LogRecord; context is conveyed via `traceId`/`spanId` | +| `span.d` | _(not mapped)_ | Log records have no duration | +| `span.k` | _(not mapped)_ | Log records have no span kind | + +### 4.2 Level → SeverityNumber Mapping Table + +| Instana `level` | `severityText` | `severityNumber` | Rationale | +|---|---|---|---| +| `trace` | `TRACE` | 1 | OTel TRACE1 | +| `debug` | `DEBUG` | 5 | OTel DEBUG1 | +| `info` | `INFO` | 9 | OTel INFO1 | +| `warn` | `WARN` | 13 | OTel WARN1 | +| `error` | `ERROR` | 17 | OTel ERROR1 | +| `fatal` | `FATAL` | 21 | OTel FATAL1 | +| _(unknown)_ | `""` | 0 | OTel SEVERITY_NUMBER_UNSPECIFIED | + +### 4.3 Example Transformed Output + +**Input Instana log span:** +```json +{ + "t": "abc123def456", + "s": "1234567890abcdef", + "p": "fedcba0987654321", + "n": "log.winston", + "k": 2, + "ts": 1706000000000, + "d": 1, + "ec": 1, + "f": { "e": "42", "h": "my-host" }, + "data": { + "log": { + "message": "Database connection failed", + "level": "error" + } + } +} +``` + +**Output OTLP LogRecord:** +```json +{ + "timeUnixNano": "1706000000000000000", + "observedTimeUnixNano": "1706000000000000000", + "severityNumber": 17, + "severityText": "ERROR", + "body": { "stringValue": "Database connection failed" }, + "traceId": "00000000000000000000abc123def456", + "spanId": "1234567890abcdef", + "attributes": [] +} +``` + +**Wrapped in resourceLogs container:** +```json +{ + "resourceLogs": [ + { + "resource": { + "attributes": [ + { "key": "service.name", "value": { "stringValue": "my-service" } }, + { "key": "telemetry.sdk.language", "value": { "stringValue": "nodejs" } }, + { "key": "telemetry.sdk.name", "value": { "stringValue": "instana" } }, + { "key": "telemetry.sdk.version", "value": { "stringValue": "3.x.x" } }, + { "key": "process.pid", "value": { "intValue": 42 } }, + { "key": "host.name", "value": { "stringValue": "my-host" } } + ] + }, + "scopeLogs": [ + { + "scope": { "name": "@instana/collector", "version": "3.x.x" }, + "logRecords": [ + { + "timeUnixNano": "1706000000000000000", + "observedTimeUnixNano": "1706000000000000000", + "severityNumber": 17, + "severityText": "ERROR", + "body": { "stringValue": "Database connection failed" }, + "traceId": "00000000000000000000abc123def456", + "spanId": "1234567890abcdef", + "attributes": [] + } + ] + } + ] + } + ] +} +``` + +--- + +## 5. Design Decisions & Open Questions + +### 5.1 Separate signal vs. unified converter + +OTel treats Logs as a **separate signal** from Traces — the OTLP protobuf uses a distinct `ExportLogsServiceRequest` with `resourceLogs`. The current `traces/converter.js` `convert()` function returns `{ resourceSpans: [...] }`. + +**Options:** + +| Option | Description | Verdict | +|--------|-------------|---------| +| **A: Separate `logs/` module** (mirrors `traces/` and `metrics/`) | New `otlpExporter/logs/converter.js` with `convert(spans)` that returns `{ resourceLogs: [...] }`. Log spans are separated from trace spans before the main loop, or the logs converter is called in parallel. | ✅ **Recommended** — clean signal separation, follows OTel model, easy to test | +| B: Mixed output in traces converter | `traces/converter.js` returns both `resourceSpans` and `resourceLogs` | ❌ Violates OTel signal separation — a single export call cannot mix trace and log payloads in protobuf | +| C: Convert log spans as trace spans | Map `data.log.message` → span name, pretend they are INTERNAL spans | ❌ Lossy — severity, `traceId`, `spanId` on LogRecord serve observability differently than trace spans | + +### 5.2 Connector endpoint + +Logs use the `/v1/logs` OTLP endpoint (or gRPC `opentelemetry.proto.collector.logs.v1.LogsService`), **separate** from `/v1/traces`. The HTTP exporter path needs to be differentiated. This is a concern for the transport layer (outside this module), but the logs converter must produce `{ resourceLogs }` not `{ resourceSpans }`. + +### 5.3 Stack trace → `code.*` attributes + +`span.stack` is an array of call frames, populated by `tracingUtil.getStackTrace()`. Frame shape depends on `stackTraceMode` config. If available, the **first meaningful user frame** (not an Instana internal frame) should map to `code.function`, `code.filepath`, `code.lineno`. + +Decision: **optional enhancement** — only emit `code.*` attributes if `span.stack` is non-empty. Gate behind a utility function similar to how exceptions map `exception.stacktrace`. + +### 5.4 `log.iostream` for console spans + +`log.console` spans use `console.error` → `stderr` and `console.warn` / `console.info` → `stdout`. This can be set statically based on `span.n` + `data.log.level`: + +```js +// level 'error' on console → stderr, all others → stdout +const iostream = span.n === 'log.console' && span.data.log.level === 'error' + ? 'stderr' + : 'stdout'; +``` + +This is a low-priority attribute — mark as **optional**. + +### 5.5 `traceFlags` + +OTel `traceFlags` is a uint32, bit 0 = sampled. Since Instana has already decided to transmit this span, it is always sampled. Set `traceFlags = 1`. + +### 5.6 `observedTimeUnixNano` + +Instana does not record "observed" vs "occurred" separately. Use `span.ts * 1_000_000` for both `timeUnixNano` and `observedTimeUnixNano`. Per OTel spec, `observedTimeUnixNano` MUST be set when `timeUnixNano` is unknown — since we always have `ts`, both are set to the same value. + +### 5.7 Semconv version handling + +The base mappings already include: + +```js +// packages/core/src/otlpExporter/common/semconv/base/mappings.js +log: { + BODY: 'log.body', // ← non-standard, spec uses 'body' as a top-level field + SEVERITY: 'log.severity', // ← non-standard + FUNCTION: 'code.function' +} +``` + +> ⚠️ **Discrepancy**: `log.body` and `log.severity` are **not** OTel OTLP LogRecord attribute keys — they are top-level `LogRecord` proto fields (`body`, `severityNumber`/`severityText`). The `base/mappings.js` entries likely pre-date the log implementation. The logs converter should **not** use these as attribute keys; it should emit them as proper LogRecord top-level fields. + +`code.function` in base mappings is correct as a span attribute key from OTel [code semconv](https://opentelemetry.io/docs/specs/semconv/general/attributes/#source-code-attributes). + +--- + +## 6. Proposed File Structure + +``` +packages/core/src/otlpExporter/ +├── index.js ← add logs.init(config); logs export +├── common/ ← shared (unchanged) +│ └── transformers/resource.js ← reused for resourceLogs resource extraction +├── traces/ ← unchanged +├── metrics/ ← unchanged +└── logs/ ← NEW + ├── index.js ← { init, convert } + ├── converter.js ← main convert(spans) → { resourceLogs } + └── transformers/ + ├── index.js + └── logRecord.js ← extractLogRecord(span) → LogRecord object +``` + +--- + +## 7. Implementation Plan (Phased) + +### Phase 1 — Core log record conversion (MVP) + +1. **Create `logs/converter.js`** + - Accept `spans` array (same input as traces converter) + - Filter to log spans via `isLogSpan(span)` from `traces/util.js` (move to `common/util.js` or re-import) + - For each log span, call `transformers.logRecord.extractLogRecord(span)` + - Build and return `{ resourceLogs: [{ resource, scopeLogs: [{ scope, logRecords }] }] }` + +2. **Create `logs/transformers/logRecord.js`** + - Map all fields per §4.1 table + - Severity mapping per §4.2 table (hard-coded constant map, no semconv versioning needed — these are top-level fields not attributes) + - `traceId` / `spanId` padded the same way as `spanMetaData.js` + +3. **Update `index.js`** + - `require('./logs')` and call `logs.init(config)` in `init()` + - Export `logs` + +4. **Update `traces/converter.js`** + - Remove the `continue` skip; instead collect log spans separately and pass to `logs.converter.convert()` + - Or: keep logs converter fully independent (called from outside), just remove the `continue` and let caller handle routing + +### Phase 2 — Optional attributes + +5. **`code.*` attributes** from `span.stack` (if non-empty) +6. **`log.iostream`** for `log.console` spans +7. **`log.record.uid`** — could be `span.s` (span ID) since it is unique + +--- + +## 8. Unchanged / Out of Scope + +- Semconv versioning (v1.23 vs v1.41): log record top-level fields (`body`, `severityNumber`) are not semconv-versioned attributes — they are proto fields. Only `code.*` attributes (if added) are stable across versions and need no versioning. +- `span.data.log.message` truncation: already handled upstream by each logger instrumentation (e.g., bunyan truncates to 500 chars). +- Transport/endpoint routing (HTTP `/v1/logs` vs `/v1/traces`): outside scope of this converter module. +- `traceFlags` from W3C `traceparent`: not available in Instana span model — default to `1` (sampled). + +--- + +## 9. Reference Links + +- [OTel Logs Data Model spec](https://opentelemetry.io/docs/specs/otel/logs/data-model/) +- [OTel Semantic Conventions — Logs](https://opentelemetry.io/docs/specs/semconv/general/logs/) +- [OTel Concepts — Logs signal](https://opentelemetry.io/docs/concepts/signals/logs/) +- [OTel proto: logs/v1/logs.proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/logs/v1/logs.proto) +- [OTel semconv — Source code attributes](https://opentelemetry.io/docs/specs/semconv/general/attributes/#source-code-attributes) +- Instana logging instrumentations: [`logging/pino.js`](../tracing/instrumentation/logging/pino.js), [`logging/winston.js`](../tracing/instrumentation/logging/winston.js), [`logging/bunyan.js`](../tracing/instrumentation/logging/bunyan.js), [`logging/log4js.js`](../tracing/instrumentation/logging/log4js.js), [`logging/console.js`](../tracing/instrumentation/logging/console.js) +- Instana log level constants: [`util/constants.js`](../util/constants.js) +- Log span detection: [`otlpExporter/traces/util.js`](./traces/util.js) diff --git a/packages/core/src/otlpExporter/common/semconv/base/mappings.js b/packages/core/src/otlpExporter/common/semconv/base/mappings.js index 93ef29869f..c43ffd20da 100644 --- a/packages/core/src/otlpExporter/common/semconv/base/mappings.js +++ b/packages/core/src/otlpExporter/common/semconv/base/mappings.js @@ -122,6 +122,28 @@ const MAPPINGS = { error: { TYPE: 'error.type' + }, + + metrics: { + v8js: { + GC_DURATION: 'v8js.gc.duration', + HEAP_SPACE_AVAILABLE_SIZE: 'v8js.memory.heap.space.available_size', + HEAP_SPACE_PHYSICAL_SIZE: 'v8js.memory.heap.space.physical_size', + HEAP_SPACE_SIZE: 'v8js.memory.heap.space.size', + HEAP_USED: 'v8js.memory.heap.used', + RESOURCE_ACTIVE: 'v8js.resource.active', + + attributes: { + GC_TYPE: 'v8js.gc.type', + HEAP_SPACE_NAME: 'v8js.heap.space.name', + RESOURCE_TYPE: 'v8js.resource.type' + } + }, + nodejs: { + EVENTLOOP_DELAY_MIN: 'nodejs.eventloop.delay.min', + EVENTLOOP_DELAY_MAX: 'nodejs.eventloop.delay.max', + EVENTLOOP_DELAY_MEAN: 'nodejs.eventloop.delay.mean' + } } }; diff --git a/packages/core/src/otlpExporter/metrics/converter.js b/packages/core/src/otlpExporter/metrics/converter.js index 2be367ab49..6546ce2265 100644 --- a/packages/core/src/otlpExporter/metrics/converter.js +++ b/packages/core/src/otlpExporter/metrics/converter.js @@ -7,6 +7,7 @@ const otlpCtx = require('../common/context'); const { normalizeMetrics } = require('./util'); const transformers = require('./transformers'); +const mappers = require('./mappers'); const { INSTRUMENTATION_SCOPE } = transformers.resource; @@ -54,8 +55,7 @@ function convert(metrics) { scopeMetrics: [ { scope: INSTRUMENTATION_SCOPE, - // TODO: implement metrics transformation later in phase2 - metrics: [] + metrics: transformers.runtimeMetrics.extractMetrics(metrics, mappers.get(metrics)) } ] } diff --git a/packages/core/src/otlpExporter/metrics/mappers/constants.js b/packages/core/src/otlpExporter/metrics/mappers/constants.js new file mode 100644 index 0000000000..59fbe10f26 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/constants.js @@ -0,0 +1,17 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +exports.METRIC_TYPES = { + GAUGE: 'gauge', + UPDOWNCOUNTER: 'updowncounter', + HISTOGRAM: 'histogram' +}; + +exports.METRIC_UNITS = { + SECONDS: 's', + BYTES: 'By', + RESOURCES: '{resource}' +}; diff --git a/packages/core/src/otlpExporter/metrics/mappers/index.js b/packages/core/src/otlpExporter/metrics/mappers/index.js new file mode 100644 index 0000000000..47ea807f5c --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/index.js @@ -0,0 +1,18 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const runtimeMetrics = require('./runtimeMetrics'); + +/** + * @returns {{ metricMappings: import('./runtimeMetrics').MetricMapping[] }} + */ +function get() { + return runtimeMetrics; +} + +module.exports = { + get +}; diff --git a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js new file mode 100644 index 0000000000..739e7c1ebe --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js @@ -0,0 +1,163 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const ctx = require('../../common/context'); +const { METRIC_TYPES, METRIC_UNITS } = require('./constants'); + +const OTLP = /** @type {any} */ (ctx.semConv); + +/** + * @typedef {Object} MetricDataPointMapping + * @property {string} instana - dot-path into the Instana payload (e.g. 'gc.gcPause') + * @property {Record} attributes - fixed OTel attribute set for this data point + * @property {(value: any) => any} [transform] - optional value transform + */ + +/** + * @typedef {Object} MetricMapping + * @property {string} name - OTel metric name (from semConv) + * @property {string} unit - OTel unit string + * @property {string} type - 'gauge' | 'updowncounter' | 'histogram' + * @property {string} instanaPrefix - top-level key in the Instana payload that must exist + * @property {(payload: Record) => Array<{attributes: Record, value: any}> | null} dataPoints + * - returns the data-point array for this metric, or null when the source field is absent + */ + +/** @type {MetricMapping[]} */ +const v8Mappings = [ + { + name: OTLP.metrics.v8js.GC_DURATION, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.HISTOGRAM, + instanaPrefix: 'gc', + dataPoints(payload) { + const gc = payload.gc; + if (!gc || typeof gc.gcPause !== 'number') return null; + return [ + { + attributes: { [OTLP.metrics.v8js.attributes.GC_TYPE]: 'all' }, + value: { count: 1, sum: gc.gcPause / 1000 } + } + ]; + } + }, + + { + name: OTLP.metrics.v8js.HEAP_SPACE_AVAILABLE_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instanaPrefix: 'heapSpaces', + dataPoints(payload) { + const heapSpaces = payload.heapSpaces; + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + const points = Object.entries(heapSpaces) + .filter(([, s]) => s && typeof s.available === 'number') + .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.available })); + return points.length ? points : null; + } + }, + + { + name: OTLP.metrics.v8js.HEAP_SPACE_PHYSICAL_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instanaPrefix: 'heapSpaces', + dataPoints(payload) { + const heapSpaces = payload.heapSpaces; + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + const points = Object.entries(heapSpaces) + .filter(([, s]) => s && typeof s.physical === 'number') + .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.physical })); + return points.length ? points : null; + } + }, + + { + name: OTLP.metrics.v8js.HEAP_SPACE_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instanaPrefix: 'heapSpaces', + dataPoints(payload) { + const heapSpaces = payload.heapSpaces; + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + const points = Object.entries(heapSpaces) + .filter(([, s]) => s && typeof s.current === 'number') + .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.current })); + return points.length ? points : null; + } + }, + + { + name: OTLP.metrics.v8js.HEAP_USED, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instanaPrefix: 'heapSpaces', + dataPoints(payload) { + const heapSpaces = payload.heapSpaces; + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + const points = Object.entries(heapSpaces) + .filter(([, s]) => s && typeof s.used === 'number') + .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.used })); + return points.length ? points : null; + } + }, + + { + name: OTLP.metrics.v8js.RESOURCE_ACTIVE, + unit: METRIC_UNITS.RESOURCES, + type: METRIC_TYPES.GAUGE, + instanaPrefix: 'activeResources', + dataPoints(payload) { + const ar = payload.activeResources; + if (!ar || typeof ar.count !== 'number') return null; + return [{ attributes: { [OTLP.metrics.v8js.attributes.RESOURCE_TYPE]: 'all' }, value: ar.count }]; + } + } +]; + +/** @type {MetricMapping[]} */ +const nodejsMappings = [ + { + name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MIN, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instanaPrefix: 'libuv', + dataPoints(payload) { + const libuv = payload.libuv; + if (!libuv || typeof libuv.min !== 'number') return null; + return [{ attributes: {}, value: libuv.min / 1000 }]; + } + }, + + { + name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MAX, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instanaPrefix: 'libuv', + dataPoints(payload) { + const libuv = payload.libuv; + if (!libuv || typeof libuv.max !== 'number') return null; + return [{ attributes: {}, value: libuv.max / 1000 }]; + } + }, + + { + // Derived: sum / num. Requires num > 0. + name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MEAN, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instanaPrefix: 'libuv', + dataPoints(payload) { + const libuv = payload.libuv; + if (!libuv || typeof libuv.sum !== 'number' || typeof libuv.num !== 'number' || libuv.num === 0) return null; + return [{ attributes: {}, value: libuv.sum / libuv.num / 1000 }]; + } + } +]; + +module.exports = { + metricMappings: [...v8Mappings, ...nodejsMappings] +}; diff --git a/packages/core/src/otlpExporter/metrics/transformers/index.js b/packages/core/src/otlpExporter/metrics/transformers/index.js index ea8239c3e9..9fae86f9cb 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/index.js +++ b/packages/core/src/otlpExporter/metrics/transformers/index.js @@ -5,7 +5,10 @@ 'use strict'; const resource = require('../../common/transformers/resource'); +const runtimeMetrics = require('./runtimeMetrics'); module.exports = { - resource + resource, + /** Engine: iterates mapper.metricMappings → OTLP metric array */ + runtimeMetrics }; diff --git a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js new file mode 100644 index 0000000000..51b799b0fc --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js @@ -0,0 +1,46 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @typedef {import('../mappers/runtimeMetrics').MetricMapping} MetricMapping + */ + +/** + * Iterates the mapper's declarative metricMappings and produces an array of + * OTLP metric objects for the given Instana metrics payload. + * + * This is the engine — it knows nothing about specific metric names or field + * paths; all that knowledge lives in the mapper's mapping tables. + * + * @param {Record} metricsPayload Top-level Instana metrics object + * @param {{ metricMappings: MetricMapping[] }} mapper + * @returns {Array<{ descriptor: { name: string, unit: string }, type: string, dataPoints: Array }>} + */ +function extractMetrics(metricsPayload, mapper) { + if (!metricsPayload || !mapper || !Array.isArray(mapper.metricMappings)) { + return []; + } + + /** @type {Array<{ descriptor: { name: string, unit: string }, type: string, dataPoints: Array }>} */ + const result = []; + + for (const mapping of mapper.metricMappings) { + const dataPoints = mapping.dataPoints(metricsPayload); + if (!dataPoints) continue; + + result.push({ + descriptor: { name: mapping.name, unit: mapping.unit }, + type: mapping.type, + dataPoints + }); + } + + return result; +} + +module.exports = { + extractMetrics +}; diff --git a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json index 1ba936ae13..9c5234d42c 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json @@ -37,6 +37,7 @@ }, "keywords": ["opentelemetry", "instana", "tracing"], "libuv": { + "min": 0, "max": 496, "num": 241, "sum": 1003 diff --git a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json index ef10be04e2..e6eec100b6 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json @@ -11,7 +11,78 @@ { "key": "host.name", "value": { "stringValue": "test-hostname" } } ] }, - "scopeMetrics": [{ "scope": { "name": "@instana/collector", "version": "6.0.0" }, "metrics": [] }] + "scopeMetrics": [ + { + "scope": { "name": "@instana/collector", "version": "6.0.0" }, + "metrics": [ + { + "descriptor": { "name": "v8js.memory.heap.space.available_size", "unit": "By" }, + "type": "updowncounter", + "dataPoints": [ + { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 6153600 }, + { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 522192 }, + { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 345536 }, + { "attributes": { "v8js.heap.space.name": "trusted_space" }, "value": 370768 } + ] + }, + { + "descriptor": { "name": "v8js.memory.heap.space.physical_size", "unit": "By" }, + "type": "updowncounter", + "dataPoints": [ + { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 27901952 }, + { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 17039360 }, + { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1572864 } + ] + }, + { + "descriptor": { "name": "v8js.memory.heap.space.size", "unit": "By" }, + "type": "updowncounter", + "dataPoints": [ + { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 16859136 }, + { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1572864 } + ] + }, + { + "descriptor": { "name": "v8js.memory.heap.used", "unit": "By" }, + "type": "updowncounter", + "dataPoints": [ + { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 10622592 }, + { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 16309064 }, + { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1227136 }, + { "attributes": { "v8js.heap.space.name": "trusted_space" }, "value": 2659888 } + ] + }, + { + "descriptor": { "name": "v8js.resource.active", "unit": "{resource}" }, + "type": "gauge", + "dataPoints": [ + { "attributes": { "v8js.resource.type": "all" }, "value": 3 } + ] + }, + { + "descriptor": { "name": "nodejs.eventloop.delay.min", "unit": "s" }, + "type": "gauge", + "dataPoints": [ + { "attributes": {}, "value": 0 } + ] + }, + { + "descriptor": { "name": "nodejs.eventloop.delay.max", "unit": "s" }, + "type": "gauge", + "dataPoints": [ + { "attributes": {}, "value": 0.496 } + ] + }, + { + "descriptor": { "name": "nodejs.eventloop.delay.mean", "unit": "s" }, + "type": "gauge", + "dataPoints": [ + { "attributes": {}, "value": 0.004161825726141079 } + ] + } + ] + } + ] } ] } diff --git a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js new file mode 100644 index 0000000000..f5ad4e4221 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js @@ -0,0 +1,237 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { MAPPINGS } = require('../../../../src/otlpExporter/common/semconv/base/mappings'); +const V8 = MAPPINGS.metrics.v8js; +const NODEJS = MAPPINGS.metrics.nodejs; + +const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetrics'); + +const FULL_PAYLOAD = { + gc: { gcPause: 414 }, + heapSpaces: { + new_space: { available: 5972864, used: 10622592, physical: 27901952, current: 6291456 }, + old_space: { available: 25165824, used: 16309064, physical: 17039360, current: 16859136 } + }, + activeResources: { count: 18 }, + libuv: { min: 0, max: 582, sum: 4820, num: 42 } +}; + +/** + * @param {string} name + */ +function findMapping(name) { + return mapper.metricMappings.find(m => m.name === name); +} + +describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { + describe('metricMappings', () => { + it('exports 9 mapping entries', () => { + expect(mapper.metricMappings).to.have.length(9); + }); + }); + + describe('v8js.gc.duration', () => { + let mapping; + before(() => { + mapping = findMapping(V8.GC_DURATION); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('s'); + expect(mapping.type).to.equal('histogram'); + }); + + it('converts gcPause ms → seconds as Histogram sum', () => { + const points = mapping.dataPoints({ gc: { gcPause: 414 } }); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.GC_TYPE]: 'all' }, value: { count: 1, sum: 0.414 } } + ]); + }); + + it('returns null when gc is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + + it('returns null when gcPause is not a number', () => { + expect(mapping.dataPoints({ gc: { gcPause: null } })).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.available_size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_AVAILABLE_SIZE); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('By'); + expect(mapping.type).to.equal('updowncounter'); + }); + + it('emits one data-point per space that has available', () => { + const points = mapping.dataPoints(FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 5972864 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 25165824 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + + it('returns null when no space has available', () => { + expect(mapping.dataPoints({ heapSpaces: { x: { current: 1 } } })).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.physical_size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_PHYSICAL_SIZE); + }); + + it('emits one data-point per space that has physical', () => { + const points = mapping.dataPoints(FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 27901952 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 17039360 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_SIZE); + }); + + it('emits one data-point per space that has current', () => { + const points = mapping.dataPoints(FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 6291456 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16859136 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + }); + + describe('v8js.memory.heap.used', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_USED); + }); + + it('emits one data-point per space that has used', () => { + const points = mapping.dataPoints(FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 10622592 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16309064 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + }); + + describe('v8js.resource.active', () => { + let mapping; + before(() => { + mapping = findMapping(V8.RESOURCE_ACTIVE); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('{resource}'); + expect(mapping.type).to.equal('gauge'); + }); + + it('maps activeResources.count with resource.type = "all"', () => { + const points = mapping.dataPoints({ activeResources: { count: 18 } }); + expect(points).to.deep.equal([{ attributes: { [V8.attributes.RESOURCE_TYPE]: 'all' }, value: 18 }]); + }); + + it('returns null when activeResources is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + + it('returns null when count is not a number', () => { + expect(mapping.dataPoints({ activeResources: { count: null } })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.min', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MIN); + }); + + it('converts libuv.min ms → seconds', () => { + const points = mapping.dataPoints({ libuv: { min: 0 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0 }]); + }); + + it('converts non-zero min', () => { + const points = mapping.dataPoints({ libuv: { min: 5000 } }); + expect(points[0].value).to.equal(5); + }); + + it('returns null when libuv is missing', () => { + expect(mapping.dataPoints({})).to.be.null; + }); + + it('returns null when min is not a number', () => { + expect(mapping.dataPoints({ libuv: { min: null } })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.max', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MAX); + }); + + it('converts libuv.max ms → seconds', () => { + const points = mapping.dataPoints({ libuv: { max: 582 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0.582 }]); + }); + + it('returns null when max is absent', () => { + expect(mapping.dataPoints({ libuv: {} })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.mean', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MEAN); + }); + + it('derives mean from sum / num and converts ms → seconds', () => { + const points = mapping.dataPoints({ libuv: { sum: 4200, num: 42 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0.1 }]); + }); + + it('returns null when num is 0 (avoids division by zero)', () => { + expect(mapping.dataPoints({ libuv: { sum: 100, num: 0 } })).to.be.null; + }); + + it('returns null when sum or num is missing', () => { + expect(mapping.dataPoints({ libuv: { sum: 100 } })).to.be.null; + expect(mapping.dataPoints({ libuv: { num: 5 } })).to.be.null; + }); + }); +}); diff --git a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js new file mode 100644 index 0000000000..e750d3b6e1 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js @@ -0,0 +1,78 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { extractMetrics } = require('../../../../src/otlpExporter/metrics/transformers/runtimeMetrics'); +const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetrics'); + +const FULL_PAYLOAD = { + gc: { gcPause: 414 }, + heapSpaces: { + new_space: { available: 5972864, used: 10622592, physical: 27901952, current: 6291456 }, + old_space: { available: 25165824, used: 16309064, physical: 17039360, current: 16859136 } + }, + activeResources: { count: 18 }, + libuv: { min: 0, max: 582, sum: 4820, num: 42 } +}; + +describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { + describe('extractMetrics', () => { + it('produces all 9 metrics from a full payload', () => { + const result = extractMetrics(FULL_PAYLOAD, mapper); + const names = result.map(m => m.descriptor.name); + expect(names).to.deep.equal([ + 'v8js.gc.duration', + 'v8js.memory.heap.space.available_size', + 'v8js.memory.heap.space.physical_size', + 'v8js.memory.heap.space.size', + 'v8js.memory.heap.used', + 'v8js.resource.active', + 'nodejs.eventloop.delay.min', + 'nodejs.eventloop.delay.max', + 'nodejs.eventloop.delay.mean' + ]); + }); + + it('each metric has descriptor, type and dataPoints', () => { + const result = extractMetrics(FULL_PAYLOAD, mapper); + for (const m of result) { + expect(m).to.have.property('descriptor').that.has.keys(['name', 'unit']); + expect(m).to.have.property('type').that.is.a('string'); + expect(m).to.have.property('dataPoints').that.is.an('array').with.length.greaterThan(0); + } + }); + + it('returns an empty array for an empty payload', () => { + expect(extractMetrics({}, mapper)).to.deep.equal([]); + }); + + it('returns an empty array for null payload', () => { + expect(extractMetrics(null, mapper)).to.deep.equal([]); + }); + + it('returns an empty array for null mapper', () => { + expect(extractMetrics(FULL_PAYLOAD, null)).to.deep.equal([]); + }); + + it('only emits metrics whose source fields are present', () => { + const result = extractMetrics({ libuv: { min: 10, max: 200, sum: 500, num: 5 } }, mapper); + const names = result.map(m => m.descriptor.name); + expect(names).to.deep.equal([ + 'nodejs.eventloop.delay.min', + 'nodejs.eventloop.delay.max', + 'nodejs.eventloop.delay.mean' + ]); + }); + + it('gc.duration has histogram type with count and sum in value', () => { + const result = extractMetrics({ gc: { gcPause: 1000 } }, mapper); + const gcMetric = result.find(m => m.descriptor.name === 'v8js.gc.duration'); + expect(gcMetric.type).to.equal('histogram'); + expect(gcMetric.dataPoints[0].value).to.deep.equal({ count: 1, sum: 1 }); + }); + }); +}); From fdcb9fb34f1b450dc651c927cbc2ca512c5175a1 Mon Sep 17 00:00:00 2001 From: Arya Date: Tue, 4 Aug 2026 09:59:01 +0530 Subject: [PATCH 2/3] chore: updated --- .../src/otlpExporter/LOGS_SIGNAL_RESEARCH.md | 417 ------------------ .../src/otlpExporter/metrics/converter.js | 4 +- .../src/otlpExporter/metrics/mappers/index.js | 7 +- ...meMetrics.js => runtimeMetricsMappings.js} | 60 +-- .../src/otlpExporter/metrics/mappers/util.js | 48 ++ .../metrics/transformers/index.js | 1 - .../metrics/transformers/runtimeMetrics.js | 78 +++- .../otlpExporter/metrics/transformers/util.js | 64 +++ .../metrics/fixtures/input/metrics.json | 1 + .../fixtures/output/metrics-output.json | 190 ++++++-- ...test.js => runtimeMetricsMappings_test.js} | 2 +- .../transformers/runtimeMetrics_test.js | 61 ++- 12 files changed, 384 insertions(+), 549 deletions(-) delete mode 100644 packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md rename packages/core/src/otlpExporter/metrics/mappers/{runtimeMetrics.js => runtimeMetricsMappings.js} (50%) create mode 100644 packages/core/src/otlpExporter/metrics/mappers/util.js create mode 100644 packages/core/src/otlpExporter/metrics/transformers/util.js rename packages/core/test/otlpExporter/metrics/mappers/{runtimeMetrics_test.js => runtimeMetricsMappings_test.js} (99%) diff --git a/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md b/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md deleted file mode 100644 index c7bfba0add..0000000000 --- a/packages/core/src/otlpExporter/LOGS_SIGNAL_RESEARCH.md +++ /dev/null @@ -1,417 +0,0 @@ -# OTLP Logs Signal — Research & Mapping Document - -> **Author:** Research for `feat-otlp-exporter` -> **Scope:** `packages/core/src/otlpExporter/` -> **Status:** Draft — pre-implementation - ---- - -## 1. Context - -The OTLP exporter currently handles two signals: - -| Signal | Entry point | Status | -|---------|--------------------------------------------|-------------| -| Traces | `traces/converter.js` → `convert(spans)` | ✅ Implemented | -| Metrics | `metrics/converter.js` → `convert(metrics)` | 🔧 Stub (Phase 2) | -| **Logs** | _(none — skipped in `traces/converter.js`)_ | 🚫 **TODO** | - -Log spans are currently skipped at the hot path in [`traces/converter.js`](./traces/converter.js): - -```js -if (isLogSpan(span)) { - // TODO: Add log span converter - continue; -} -``` - -Log spans are identified by [`traces/util.js#isLogSpan`](./traces/util.js): - -```js -function isLogSpan(span) { - if (!span) return false; - if (span.data && span.data.log) return true; - if (span.n && typeof span.n === 'string' && span.n.startsWith('log.')) return true; - return false; -} -``` - ---- - -## 2. Instana Log Span Structure - -All five logging instrumentations (pino, winston, bunyan, log4js, console) produce spans with **the same shape**. There are no extra fields beyond `message` and `level`. - -### 2.1 Shape - -```js -{ - // ── Span envelope ────────────────────────────────────────── - t: "abc123", // trace ID (hex string, 16 or 32 chars) - s: "def456", // span ID (hex string, 16 chars) - p: "789abc", // parent span ID (hex, optional) - n: "log.pino", // span name: one of the values below - k: 2, // kind: always EXIT (2) - ts: 1706000000000, // start timestamp (ms) - d: 0, // duration (ms) — always ~0 for log spans - ec: 0 | 1, // error count (1 for ERROR / FATAL) - f: { e: "12345", h: "host-id" }, // from / process identity - stack: [...], // stack trace array - - // ── Log-specific payload ──────────────────────────────────── - data: { - log: { - message: "Something went wrong", // string - level: "error" // normalized log level string (see §3) - } - } -} -``` - -### 2.2 Span name values per logger - -| Instrumentation file | `span.n` value | -|----------------------|----------------| -| `logging/pino.js` | `log.pino` | -| `logging/winston.js` | `log.winston` | -| `logging/bunyan.js` | `log.bunyan` | -| `logging/log4js.js` | `log.log4js` | -| `logging/console.js` | `log.console` | - -All start with `log.` — this is exactly what `isLogSpan()` checks via `span.n.startsWith('log.')`. - -### 2.3 Normalized log levels - -Defined in [`util/constants.js#LOG_LEVEL`](../util/constants.js): - -| String value | Priority | -|---|---| -| `trace` | 10 | -| `debug` | 20 | -| `info` | 30 | -| `warn` | 40 | -| `error` | 50 | -| `fatal` | 60 | - -`ec = 1` (error count) is set by `tracingUtil.isLogLevelAnError()` when `level === 'error'` or `level === 'fatal'`. - -Capture threshold is controlled by config `tracing.captureLogLevel` (default: `warn`), checked by `tracingUtil.shouldCaptureLogSpan()` before the span is even created — so by the time a log span reaches the OTLP exporter, it has already passed the filter. - ---- - -## 3. OTel Log Data Model Reference - -From [opentelemetry.io/docs/specs/semconv/general/logs](https://opentelemetry.io/docs/specs/semconv/general/logs/) -and [opentelemetry.io/docs/concepts/signals/logs](https://opentelemetry.io/docs/concepts/signals/logs/). - -### 3.1 OTLP Log Record top-level fields - -The OTLP `LogRecord` message (proto: `opentelemetry.proto.logs.v1.LogRecord`) has the following mandatory/optional fields: - -| OTLP field | Type | Description | -|--------------------------|------------|-------------| -| `timeUnixNano` | uint64 | Timestamp of the log event (nanoseconds since epoch) | -| `observedTimeUnixNano` | uint64 | When the log was observed / collected | -| `severityNumber` | enum (int) | Numeric severity 1–24 (see §3.2) | -| `severityText` | string | Human-readable severity string | -| `body` | AnyValue | The log message body | -| `attributes` | KeyValues | Structured key-value pairs | -| `traceId` | bytes/hex | W3C-compatible 16-byte trace ID | -| `spanId` | bytes/hex | 8-byte span ID | -| `traceFlags` | uint32 | W3C trace flags | -| `droppedAttributesCount` | uint32 | Count of dropped attributes | -| `eventName` | string | (OTel 1.41+) Optional event name | -| `flags` | uint32 | Log record flags | - -The OTLP **container** structure mirrors traces/metrics: - -```json -{ - "resourceLogs": [ - { - "resource": { "attributes": [...] }, - "scopeLogs": [ - { - "scope": { "name": "@instana/collector", "version": "..." }, - "logRecords": [ ...LogRecord ] - } - ] - } - ] -} -``` - -### 3.2 OTel Severity Number Mapping - -| OTel SeverityNumber | Range | Meaning | -|---------------------|-------|---------| -| 1–4 | TRACE, TRACE2–TRACE4 | Very low level trace | -| 5–8 | DEBUG, DEBUG2–DEBUG4 | Debug | -| 9–12 | INFO, INFO2–INFO4 | Informational | -| 13–16 | WARN, WARN2–WARN4 | Warning | -| 17–20 | ERROR, ERROR2–ERROR4 | Error | -| 21–24 | FATAL, FATAL2–FATAL4 | Fatal | - -The canonical single values per level (no suffix) are: 1, 5, 9, 13, 17, 21. - -### 3.3 Semantic Convention Attributes relevant to Logs - -From OTel semconv (general/logs + code conventions): - -| OTel attribute key | Description | -|----------------------|-------------| -| `log.record.uid` | A unique identifier for the log record | -| `log.iostream` | `stdout` or `stderr` (for console) | -| `code.function` | Function/method name that emitted the log | -| `code.filepath` | File path where log was emitted | -| `code.lineno` | Line number | -| `exception.type` | Exception class name (for error logs) | -| `exception.message` | Exception message | -| `exception.stacktrace` | Full exception stacktrace | - ---- - -## 4. Instana → OTLP Log Record Field Mapping - -### 4.1 Core Field Mapping Table - -| Instana field | OTLP LogRecord field | Transform / Notes | -|---|---|---| -| `span.ts` (ms) | `timeUnixNano` | `span.ts * 1_000_000` → nanoseconds string | -| `span.ts` (ms) | `observedTimeUnixNano` | Same as `timeUnixNano` — Instana does not distinguish observed vs occurred | -| `data.log.level` | `severityNumber` | Map via level→severity table (§4.2) | -| `data.log.level` | `severityText` | Uppercase: `"WARN"`, `"ERROR"`, etc. | -| `data.log.message` | `body` | `{ stringValue: message }` | -| `span.t` | `traceId` | Pad to 32 hex chars (same as trace converter) | -| `span.s` | `spanId` | Pad to 16 hex chars | -| `span.n` | `attributes["log.iostream"]` | `log.console` → infer `"stdout"` / `"stderr"` | -| `span.n` | `attributes["telemetry.sdk.name"]` | Can also go on resource (already done) | -| `span.stack[0]` | `attributes["code.function"]` | Frame function name if stack available | -| `span.stack[0]` | `attributes["code.filepath"]` | Frame file path if stack available | -| `span.stack[0]` | `attributes["code.lineno"]` | Frame line number if stack available | -| `span.ec` | _(drives `severityNumber`)_ | If `ec=1`, severity ≥ ERROR; used as cross-check | -| `span.p` | _(not mapped)_ | Parent span ID has no direct equivalent in LogRecord; context is conveyed via `traceId`/`spanId` | -| `span.d` | _(not mapped)_ | Log records have no duration | -| `span.k` | _(not mapped)_ | Log records have no span kind | - -### 4.2 Level → SeverityNumber Mapping Table - -| Instana `level` | `severityText` | `severityNumber` | Rationale | -|---|---|---|---| -| `trace` | `TRACE` | 1 | OTel TRACE1 | -| `debug` | `DEBUG` | 5 | OTel DEBUG1 | -| `info` | `INFO` | 9 | OTel INFO1 | -| `warn` | `WARN` | 13 | OTel WARN1 | -| `error` | `ERROR` | 17 | OTel ERROR1 | -| `fatal` | `FATAL` | 21 | OTel FATAL1 | -| _(unknown)_ | `""` | 0 | OTel SEVERITY_NUMBER_UNSPECIFIED | - -### 4.3 Example Transformed Output - -**Input Instana log span:** -```json -{ - "t": "abc123def456", - "s": "1234567890abcdef", - "p": "fedcba0987654321", - "n": "log.winston", - "k": 2, - "ts": 1706000000000, - "d": 1, - "ec": 1, - "f": { "e": "42", "h": "my-host" }, - "data": { - "log": { - "message": "Database connection failed", - "level": "error" - } - } -} -``` - -**Output OTLP LogRecord:** -```json -{ - "timeUnixNano": "1706000000000000000", - "observedTimeUnixNano": "1706000000000000000", - "severityNumber": 17, - "severityText": "ERROR", - "body": { "stringValue": "Database connection failed" }, - "traceId": "00000000000000000000abc123def456", - "spanId": "1234567890abcdef", - "attributes": [] -} -``` - -**Wrapped in resourceLogs container:** -```json -{ - "resourceLogs": [ - { - "resource": { - "attributes": [ - { "key": "service.name", "value": { "stringValue": "my-service" } }, - { "key": "telemetry.sdk.language", "value": { "stringValue": "nodejs" } }, - { "key": "telemetry.sdk.name", "value": { "stringValue": "instana" } }, - { "key": "telemetry.sdk.version", "value": { "stringValue": "3.x.x" } }, - { "key": "process.pid", "value": { "intValue": 42 } }, - { "key": "host.name", "value": { "stringValue": "my-host" } } - ] - }, - "scopeLogs": [ - { - "scope": { "name": "@instana/collector", "version": "3.x.x" }, - "logRecords": [ - { - "timeUnixNano": "1706000000000000000", - "observedTimeUnixNano": "1706000000000000000", - "severityNumber": 17, - "severityText": "ERROR", - "body": { "stringValue": "Database connection failed" }, - "traceId": "00000000000000000000abc123def456", - "spanId": "1234567890abcdef", - "attributes": [] - } - ] - } - ] - } - ] -} -``` - ---- - -## 5. Design Decisions & Open Questions - -### 5.1 Separate signal vs. unified converter - -OTel treats Logs as a **separate signal** from Traces — the OTLP protobuf uses a distinct `ExportLogsServiceRequest` with `resourceLogs`. The current `traces/converter.js` `convert()` function returns `{ resourceSpans: [...] }`. - -**Options:** - -| Option | Description | Verdict | -|--------|-------------|---------| -| **A: Separate `logs/` module** (mirrors `traces/` and `metrics/`) | New `otlpExporter/logs/converter.js` with `convert(spans)` that returns `{ resourceLogs: [...] }`. Log spans are separated from trace spans before the main loop, or the logs converter is called in parallel. | ✅ **Recommended** — clean signal separation, follows OTel model, easy to test | -| B: Mixed output in traces converter | `traces/converter.js` returns both `resourceSpans` and `resourceLogs` | ❌ Violates OTel signal separation — a single export call cannot mix trace and log payloads in protobuf | -| C: Convert log spans as trace spans | Map `data.log.message` → span name, pretend they are INTERNAL spans | ❌ Lossy — severity, `traceId`, `spanId` on LogRecord serve observability differently than trace spans | - -### 5.2 Connector endpoint - -Logs use the `/v1/logs` OTLP endpoint (or gRPC `opentelemetry.proto.collector.logs.v1.LogsService`), **separate** from `/v1/traces`. The HTTP exporter path needs to be differentiated. This is a concern for the transport layer (outside this module), but the logs converter must produce `{ resourceLogs }` not `{ resourceSpans }`. - -### 5.3 Stack trace → `code.*` attributes - -`span.stack` is an array of call frames, populated by `tracingUtil.getStackTrace()`. Frame shape depends on `stackTraceMode` config. If available, the **first meaningful user frame** (not an Instana internal frame) should map to `code.function`, `code.filepath`, `code.lineno`. - -Decision: **optional enhancement** — only emit `code.*` attributes if `span.stack` is non-empty. Gate behind a utility function similar to how exceptions map `exception.stacktrace`. - -### 5.4 `log.iostream` for console spans - -`log.console` spans use `console.error` → `stderr` and `console.warn` / `console.info` → `stdout`. This can be set statically based on `span.n` + `data.log.level`: - -```js -// level 'error' on console → stderr, all others → stdout -const iostream = span.n === 'log.console' && span.data.log.level === 'error' - ? 'stderr' - : 'stdout'; -``` - -This is a low-priority attribute — mark as **optional**. - -### 5.5 `traceFlags` - -OTel `traceFlags` is a uint32, bit 0 = sampled. Since Instana has already decided to transmit this span, it is always sampled. Set `traceFlags = 1`. - -### 5.6 `observedTimeUnixNano` - -Instana does not record "observed" vs "occurred" separately. Use `span.ts * 1_000_000` for both `timeUnixNano` and `observedTimeUnixNano`. Per OTel spec, `observedTimeUnixNano` MUST be set when `timeUnixNano` is unknown — since we always have `ts`, both are set to the same value. - -### 5.7 Semconv version handling - -The base mappings already include: - -```js -// packages/core/src/otlpExporter/common/semconv/base/mappings.js -log: { - BODY: 'log.body', // ← non-standard, spec uses 'body' as a top-level field - SEVERITY: 'log.severity', // ← non-standard - FUNCTION: 'code.function' -} -``` - -> ⚠️ **Discrepancy**: `log.body` and `log.severity` are **not** OTel OTLP LogRecord attribute keys — they are top-level `LogRecord` proto fields (`body`, `severityNumber`/`severityText`). The `base/mappings.js` entries likely pre-date the log implementation. The logs converter should **not** use these as attribute keys; it should emit them as proper LogRecord top-level fields. - -`code.function` in base mappings is correct as a span attribute key from OTel [code semconv](https://opentelemetry.io/docs/specs/semconv/general/attributes/#source-code-attributes). - ---- - -## 6. Proposed File Structure - -``` -packages/core/src/otlpExporter/ -├── index.js ← add logs.init(config); logs export -├── common/ ← shared (unchanged) -│ └── transformers/resource.js ← reused for resourceLogs resource extraction -├── traces/ ← unchanged -├── metrics/ ← unchanged -└── logs/ ← NEW - ├── index.js ← { init, convert } - ├── converter.js ← main convert(spans) → { resourceLogs } - └── transformers/ - ├── index.js - └── logRecord.js ← extractLogRecord(span) → LogRecord object -``` - ---- - -## 7. Implementation Plan (Phased) - -### Phase 1 — Core log record conversion (MVP) - -1. **Create `logs/converter.js`** - - Accept `spans` array (same input as traces converter) - - Filter to log spans via `isLogSpan(span)` from `traces/util.js` (move to `common/util.js` or re-import) - - For each log span, call `transformers.logRecord.extractLogRecord(span)` - - Build and return `{ resourceLogs: [{ resource, scopeLogs: [{ scope, logRecords }] }] }` - -2. **Create `logs/transformers/logRecord.js`** - - Map all fields per §4.1 table - - Severity mapping per §4.2 table (hard-coded constant map, no semconv versioning needed — these are top-level fields not attributes) - - `traceId` / `spanId` padded the same way as `spanMetaData.js` - -3. **Update `index.js`** - - `require('./logs')` and call `logs.init(config)` in `init()` - - Export `logs` - -4. **Update `traces/converter.js`** - - Remove the `continue` skip; instead collect log spans separately and pass to `logs.converter.convert()` - - Or: keep logs converter fully independent (called from outside), just remove the `continue` and let caller handle routing - -### Phase 2 — Optional attributes - -5. **`code.*` attributes** from `span.stack` (if non-empty) -6. **`log.iostream`** for `log.console` spans -7. **`log.record.uid`** — could be `span.s` (span ID) since it is unique - ---- - -## 8. Unchanged / Out of Scope - -- Semconv versioning (v1.23 vs v1.41): log record top-level fields (`body`, `severityNumber`) are not semconv-versioned attributes — they are proto fields. Only `code.*` attributes (if added) are stable across versions and need no versioning. -- `span.data.log.message` truncation: already handled upstream by each logger instrumentation (e.g., bunyan truncates to 500 chars). -- Transport/endpoint routing (HTTP `/v1/logs` vs `/v1/traces`): outside scope of this converter module. -- `traceFlags` from W3C `traceparent`: not available in Instana span model — default to `1` (sampled). - ---- - -## 9. Reference Links - -- [OTel Logs Data Model spec](https://opentelemetry.io/docs/specs/otel/logs/data-model/) -- [OTel Semantic Conventions — Logs](https://opentelemetry.io/docs/specs/semconv/general/logs/) -- [OTel Concepts — Logs signal](https://opentelemetry.io/docs/concepts/signals/logs/) -- [OTel proto: logs/v1/logs.proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/logs/v1/logs.proto) -- [OTel semconv — Source code attributes](https://opentelemetry.io/docs/specs/semconv/general/attributes/#source-code-attributes) -- Instana logging instrumentations: [`logging/pino.js`](../tracing/instrumentation/logging/pino.js), [`logging/winston.js`](../tracing/instrumentation/logging/winston.js), [`logging/bunyan.js`](../tracing/instrumentation/logging/bunyan.js), [`logging/log4js.js`](../tracing/instrumentation/logging/log4js.js), [`logging/console.js`](../tracing/instrumentation/logging/console.js) -- Instana log level constants: [`util/constants.js`](../util/constants.js) -- Log span detection: [`otlpExporter/traces/util.js`](./traces/util.js) diff --git a/packages/core/src/otlpExporter/metrics/converter.js b/packages/core/src/otlpExporter/metrics/converter.js index 6546ce2265..a7b2d56ff4 100644 --- a/packages/core/src/otlpExporter/metrics/converter.js +++ b/packages/core/src/otlpExporter/metrics/converter.js @@ -45,6 +45,8 @@ function convert(metrics) { // Service name resolution, it not come from first metric once it set it will be used for all metrics resolveServiceName(metrics); + const mapper = mappers.get(metrics); + // All metrics share the same resource, so we can extract the attributes from the first one const resource = transformers.resource.extractResourceAttributes(/** @type {any} */ (metricsArray[0])); @@ -55,7 +57,7 @@ function convert(metrics) { scopeMetrics: [ { scope: INSTRUMENTATION_SCOPE, - metrics: transformers.runtimeMetrics.extractMetrics(metrics, mappers.get(metrics)) + metrics: transformers.runtimeMetrics.extractMetrics(metrics, mapper) } ] } diff --git a/packages/core/src/otlpExporter/metrics/mappers/index.js b/packages/core/src/otlpExporter/metrics/mappers/index.js index 47ea807f5c..ca2a0b4e5e 100644 --- a/packages/core/src/otlpExporter/metrics/mappers/index.js +++ b/packages/core/src/otlpExporter/metrics/mappers/index.js @@ -4,12 +4,13 @@ 'use strict'; -const runtimeMetrics = require('./runtimeMetrics'); +const runtimeMetrics = require('./runtimeMetricsMappings'); /** - * @returns {{ metricMappings: import('./runtimeMetrics').MetricMapping[] }} + * @param {any} _metrics */ -function get() { +// eslint-disable-next-line no-unused-vars +function get(_metrics) { return runtimeMetrics; } diff --git a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js similarity index 50% rename from packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js rename to packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js index 739e7c1ebe..165872a2cb 100644 --- a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetrics.js +++ b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js @@ -6,24 +6,24 @@ const ctx = require('../../common/context'); const { METRIC_TYPES, METRIC_UNITS } = require('./constants'); +const { msToSeconds, heapSpacePoints, singlePoint } = require('./util'); const OTLP = /** @type {any} */ (ctx.semConv); /** * @typedef {Object} MetricDataPointMapping - * @property {string} instana - dot-path into the Instana payload (e.g. 'gc.gcPause') - * @property {Record} attributes - fixed OTel attribute set for this data point - * @property {(value: any) => any} [transform] - optional value transform + * @property {string} instana + * @property {Record} attributes + * @property {(value: any) => any} [transform] */ /** * @typedef {Object} MetricMapping - * @property {string} name - OTel metric name (from semConv) - * @property {string} unit - OTel unit string - * @property {string} type - 'gauge' | 'updowncounter' | 'histogram' - * @property {string} instanaPrefix - top-level key in the Instana payload that must exist + * @property {string} name + * @property {string} unit + * @property {string} type + * @property {string} instanaPrefix * @property {(payload: Record) => Array<{attributes: Record, value: any}> | null} dataPoints - * - returns the data-point array for this metric, or null when the source field is absent */ /** @type {MetricMapping[]} */ @@ -36,12 +36,7 @@ const v8Mappings = [ dataPoints(payload) { const gc = payload.gc; if (!gc || typeof gc.gcPause !== 'number') return null; - return [ - { - attributes: { [OTLP.metrics.v8js.attributes.GC_TYPE]: 'all' }, - value: { count: 1, sum: gc.gcPause / 1000 } - } - ]; + return singlePoint({ count: 1, sum: msToSeconds(gc.gcPause) }, { [OTLP.metrics.v8js.attributes.GC_TYPE]: 'all' }); } }, @@ -51,12 +46,7 @@ const v8Mappings = [ type: METRIC_TYPES.UPDOWNCOUNTER, instanaPrefix: 'heapSpaces', dataPoints(payload) { - const heapSpaces = payload.heapSpaces; - if (!heapSpaces || typeof heapSpaces !== 'object') return null; - const points = Object.entries(heapSpaces) - .filter(([, s]) => s && typeof s.available === 'number') - .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.available })); - return points.length ? points : null; + return heapSpacePoints(payload.heapSpaces, 'available', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); } }, @@ -66,12 +56,7 @@ const v8Mappings = [ type: METRIC_TYPES.UPDOWNCOUNTER, instanaPrefix: 'heapSpaces', dataPoints(payload) { - const heapSpaces = payload.heapSpaces; - if (!heapSpaces || typeof heapSpaces !== 'object') return null; - const points = Object.entries(heapSpaces) - .filter(([, s]) => s && typeof s.physical === 'number') - .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.physical })); - return points.length ? points : null; + return heapSpacePoints(payload.heapSpaces, 'physical', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); } }, @@ -81,12 +66,7 @@ const v8Mappings = [ type: METRIC_TYPES.UPDOWNCOUNTER, instanaPrefix: 'heapSpaces', dataPoints(payload) { - const heapSpaces = payload.heapSpaces; - if (!heapSpaces || typeof heapSpaces !== 'object') return null; - const points = Object.entries(heapSpaces) - .filter(([, s]) => s && typeof s.current === 'number') - .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.current })); - return points.length ? points : null; + return heapSpacePoints(payload.heapSpaces, 'current', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); } }, @@ -96,12 +76,7 @@ const v8Mappings = [ type: METRIC_TYPES.UPDOWNCOUNTER, instanaPrefix: 'heapSpaces', dataPoints(payload) { - const heapSpaces = payload.heapSpaces; - if (!heapSpaces || typeof heapSpaces !== 'object') return null; - const points = Object.entries(heapSpaces) - .filter(([, s]) => s && typeof s.used === 'number') - .map(([name, s]) => ({ attributes: { [OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME]: name }, value: s.used })); - return points.length ? points : null; + return heapSpacePoints(payload.heapSpaces, 'used', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); } }, @@ -113,7 +88,7 @@ const v8Mappings = [ dataPoints(payload) { const ar = payload.activeResources; if (!ar || typeof ar.count !== 'number') return null; - return [{ attributes: { [OTLP.metrics.v8js.attributes.RESOURCE_TYPE]: 'all' }, value: ar.count }]; + return singlePoint(ar.count, { [OTLP.metrics.v8js.attributes.RESOURCE_TYPE]: 'all' }); } } ]; @@ -128,7 +103,7 @@ const nodejsMappings = [ dataPoints(payload) { const libuv = payload.libuv; if (!libuv || typeof libuv.min !== 'number') return null; - return [{ attributes: {}, value: libuv.min / 1000 }]; + return singlePoint(msToSeconds(libuv.min), {}); } }, @@ -140,12 +115,11 @@ const nodejsMappings = [ dataPoints(payload) { const libuv = payload.libuv; if (!libuv || typeof libuv.max !== 'number') return null; - return [{ attributes: {}, value: libuv.max / 1000 }]; + return singlePoint(msToSeconds(libuv.max), {}); } }, { - // Derived: sum / num. Requires num > 0. name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MEAN, unit: METRIC_UNITS.SECONDS, type: METRIC_TYPES.GAUGE, @@ -153,7 +127,7 @@ const nodejsMappings = [ dataPoints(payload) { const libuv = payload.libuv; if (!libuv || typeof libuv.sum !== 'number' || typeof libuv.num !== 'number' || libuv.num === 0) return null; - return [{ attributes: {}, value: libuv.sum / libuv.num / 1000 }]; + return singlePoint(msToSeconds(libuv.sum / libuv.num), {}); } } ]; diff --git a/packages/core/src/otlpExporter/metrics/mappers/util.js b/packages/core/src/otlpExporter/metrics/mappers/util.js new file mode 100644 index 0000000000..00d3b4172a --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/util.js @@ -0,0 +1,48 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * + * @param {number} ms + * @returns {number} + */ +function msToSeconds(ms) { + return ms / 1000; +} + +/** + * @param {Record} heapSpaces + * @param {string}field + * @param {string} attributeKey + * @returns {Array<{attributes: Record, value: number}> | null} + */ +function heapSpacePoints(heapSpaces, field, attributeKey) { + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + + const points = Object.entries(heapSpaces) + .filter(([, space]) => space && typeof space[field] === 'number') + .map(([name, space]) => ({ + attributes: { [attributeKey]: name }, + value: space[field] + })); + + return points.length ? points : null; +} + +/** + * @param {any} value + * @param {Record} attributes + * @returns {Array<{attributes: Record, value: any}>} + */ +function singlePoint(value, attributes) { + return [{ attributes, value }]; +} + +module.exports = { + msToSeconds, + heapSpacePoints, + singlePoint +}; diff --git a/packages/core/src/otlpExporter/metrics/transformers/index.js b/packages/core/src/otlpExporter/metrics/transformers/index.js index 9fae86f9cb..6d8abf5898 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/index.js +++ b/packages/core/src/otlpExporter/metrics/transformers/index.js @@ -9,6 +9,5 @@ const runtimeMetrics = require('./runtimeMetrics'); module.exports = { resource, - /** Engine: iterates mapper.metricMappings → OTLP metric array */ runtimeMetrics }; diff --git a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js index 51b799b0fc..cdd455c685 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js +++ b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js @@ -4,41 +4,79 @@ 'use strict'; +const { METRIC_TYPES } = require('../mappers/constants'); +const { buildDataPoints } = require('./util'); + /** - * @typedef {import('../mappers/runtimeMetrics').MetricMapping} MetricMapping + * @typedef {import('../mappers/runtimeMetricsMappings').MetricMapping} MetricMapping */ +const OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE = 2; + /** - * Iterates the mapper's declarative metricMappings and produces an array of - * OTLP metric objects for the given Instana metrics payload. - * - * This is the engine — it knows nothing about specific metric names or field - * paths; all that knowledge lives in the mapper's mapping tables. + * @param {string} type + * @param {Array>} dataPoints + * @returns {Record} OTLP + */ +function buildMetricEnvelope(type, dataPoints) { + switch (type) { + case METRIC_TYPES.UPDOWNCOUNTER: + return { + sum: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + isMonotonic: false, + dataPoints + } + }; + + case METRIC_TYPES.HISTOGRAM: + return { + histogram: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + dataPoints + } + }; + + case METRIC_TYPES.GAUGE: + default: + return { + gauge: { + dataPoints + } + }; + } +} + +/** + * Converts Instana runtime metrics into OTLP metric objects. * - * @param {Record} metricsPayload Top-level Instana metrics object + * @param {Record} metricsPayload * @param {{ metricMappings: MetricMapping[] }} mapper - * @returns {Array<{ descriptor: { name: string, unit: string }, type: string, dataPoints: Array }>} + * @returns {Array>} OTLP */ function extractMetrics(metricsPayload, mapper) { - if (!metricsPayload || !mapper || !Array.isArray(mapper.metricMappings)) { + if (!metricsPayload || !Array.isArray(mapper?.metricMappings)) { return []; } - /** @type {Array<{ descriptor: { name: string, unit: string }, type: string, dataPoints: Array }>} */ - const result = []; + const timeUnixNano = (metricsPayload.timestamp ?? Date.now()) * 1e6; + + return mapper.metricMappings.reduce((metrics, mapping) => { + const rawDataPoints = mapping.dataPoints(metricsPayload); - for (const mapping of mapper.metricMappings) { - const dataPoints = mapping.dataPoints(metricsPayload); - if (!dataPoints) continue; + if (!rawDataPoints) { + return metrics; + } - result.push({ - descriptor: { name: mapping.name, unit: mapping.unit }, - type: mapping.type, - dataPoints + // @ts-ignore + metrics.push({ + name: mapping.name, + unit: mapping.unit, + ...buildMetricEnvelope(mapping.type, buildDataPoints(rawDataPoints, timeUnixNano)) }); - } - return result; + return metrics; + }, []); } module.exports = { diff --git a/packages/core/src/otlpExporter/metrics/transformers/util.js b/packages/core/src/otlpExporter/metrics/transformers/util.js new file mode 100644 index 0000000000..6fc7877dee --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/transformers/util.js @@ -0,0 +1,64 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @param {Record} attributes + * @returns {Array<{ key: string, value: Record }>} + */ +function formatAttributes(attributes) { + return Object.keys(attributes).map(key => { + const val = attributes[key]; + const type = typeof val; + let value; + + if (type === 'number') { + value = Number.isInteger(val) ? { intValue: val } : { doubleValue: val }; + } else if (type === 'boolean') { + value = { boolValue: val }; + } else { + value = { stringValue: String(val) }; + } + + return { key, value }; + }); +} + +/** + * Serialises the raw data-points produced by a mapper into OTLP data-point + * objects, adding `timeUnixNano` and converting the `attributes` map into the + * OTLP key-value array format. + * + * @param {Array<{ attributes: Record, value: any }>} rawPoints + * @param {number} timeUnixNano + * @returns {Array>} + */ +function buildDataPoints(rawPoints, timeUnixNano) { + return rawPoints.map(point => { + const val = point.value; + const type = typeof val; + let numericField; + + if (type === 'number') { + numericField = Number.isInteger(val) ? { asInt: val } : { asDouble: val }; + } else if (val !== null && type === 'object' && ('count' in val || 'sum' in val)) { + // histogram value shape: { count, sum } + numericField = { count: String(val.count), sum: val.sum }; + } else { + numericField = { asDouble: Number(val) }; + } + + return { + ...numericField, + timeUnixNano: String(timeUnixNano), + attributes: formatAttributes(point.attributes) + }; + }); +} + +module.exports = { + formatAttributes, + buildDataPoints +}; diff --git a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json index 9c5234d42c..f19ab2ff6c 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json @@ -1,4 +1,5 @@ { + "timestamp": 1544712660300, "activeResources": { "count": 3 }, diff --git a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json index e6eec100b6..71614f6d4c 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json @@ -16,69 +16,161 @@ "scope": { "name": "@instana/collector", "version": "6.0.0" }, "metrics": [ { - "descriptor": { "name": "v8js.memory.heap.space.available_size", "unit": "By" }, - "type": "updowncounter", - "dataPoints": [ - { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 6153600 }, - { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 522192 }, - { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 345536 }, - { "attributes": { "v8js.heap.space.name": "trusted_space" }, "value": 370768 } - ] + "name": "v8js.memory.heap.space.available_size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 6153600, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 522192, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 345536, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + }, + { + "asInt": 370768, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "trusted_space" } }] + } + ] + } }, { - "descriptor": { "name": "v8js.memory.heap.space.physical_size", "unit": "By" }, - "type": "updowncounter", - "dataPoints": [ - { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 27901952 }, - { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 17039360 }, - { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1572864 } - ] + "name": "v8js.memory.heap.space.physical_size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 27901952, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 17039360, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1572864, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + } + ] + } }, { - "descriptor": { "name": "v8js.memory.heap.space.size", "unit": "By" }, - "type": "updowncounter", - "dataPoints": [ - { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 16859136 }, - { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1572864 } - ] + "name": "v8js.memory.heap.space.size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 16859136, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1572864, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + } + ] + } }, { - "descriptor": { "name": "v8js.memory.heap.used", "unit": "By" }, - "type": "updowncounter", - "dataPoints": [ - { "attributes": { "v8js.heap.space.name": "new_space" }, "value": 10622592 }, - { "attributes": { "v8js.heap.space.name": "old_space" }, "value": 16309064 }, - { "attributes": { "v8js.heap.space.name": "code_space" }, "value": 1227136 }, - { "attributes": { "v8js.heap.space.name": "trusted_space" }, "value": 2659888 } - ] + "name": "v8js.memory.heap.used", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 10622592, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 16309064, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1227136, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + }, + { + "asInt": 2659888, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "trusted_space" } }] + } + ] + } }, { - "descriptor": { "name": "v8js.resource.active", "unit": "{resource}" }, - "type": "gauge", - "dataPoints": [ - { "attributes": { "v8js.resource.type": "all" }, "value": 3 } - ] + "name": "v8js.resource.active", + "unit": "{resource}", + "gauge": { + "dataPoints": [ + { + "asInt": 3, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.resource.type", "value": { "stringValue": "all" } }] + } + ] + } }, { - "descriptor": { "name": "nodejs.eventloop.delay.min", "unit": "s" }, - "type": "gauge", - "dataPoints": [ - { "attributes": {}, "value": 0 } - ] + "name": "nodejs.eventloop.delay.min", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asInt": 0, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } }, { - "descriptor": { "name": "nodejs.eventloop.delay.max", "unit": "s" }, - "type": "gauge", - "dataPoints": [ - { "attributes": {}, "value": 0.496 } - ] + "name": "nodejs.eventloop.delay.max", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asDouble": 0.496, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } }, { - "descriptor": { "name": "nodejs.eventloop.delay.mean", "unit": "s" }, - "type": "gauge", - "dataPoints": [ - { "attributes": {}, "value": 0.004161825726141079 } - ] + "name": "nodejs.eventloop.delay.mean", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asDouble": 0.004161825726141079, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } } ] } diff --git a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js similarity index 99% rename from packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js rename to packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js index f5ad4e4221..1695d4adf2 100644 --- a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetrics_test.js +++ b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js @@ -10,7 +10,7 @@ const { MAPPINGS } = require('../../../../src/otlpExporter/common/semconv/base/m const V8 = MAPPINGS.metrics.v8js; const NODEJS = MAPPINGS.metrics.nodejs; -const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetrics'); +const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); const FULL_PAYLOAD = { gc: { gcPause: 414 }, diff --git a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js index e750d3b6e1..55845b8e65 100644 --- a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js +++ b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js @@ -7,7 +7,7 @@ const expect = require('chai').expect; const { extractMetrics } = require('../../../../src/otlpExporter/metrics/transformers/runtimeMetrics'); -const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetrics'); +const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); const FULL_PAYLOAD = { gc: { gcPause: 414 }, @@ -16,14 +16,15 @@ const FULL_PAYLOAD = { old_space: { available: 25165824, used: 16309064, physical: 17039360, current: 16859136 } }, activeResources: { count: 18 }, - libuv: { min: 0, max: 582, sum: 4820, num: 42 } + libuv: { min: 0, max: 582, sum: 4820, num: 42 }, + timestamp: 1544712660300 }; describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { describe('extractMetrics', () => { it('produces all 9 metrics from a full payload', () => { const result = extractMetrics(FULL_PAYLOAD, mapper); - const names = result.map(m => m.descriptor.name); + const names = result.map(m => m.name); expect(names).to.deep.equal([ 'v8js.gc.duration', 'v8js.memory.heap.space.available_size', @@ -37,13 +38,14 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { ]); }); - it('each metric has descriptor, type and dataPoints', () => { + it('each metric has name, unit and the correct OTLP type envelope', () => { const result = extractMetrics(FULL_PAYLOAD, mapper); - for (const m of result) { - expect(m).to.have.property('descriptor').that.has.keys(['name', 'unit']); - expect(m).to.have.property('type').that.is.a('string'); - expect(m).to.have.property('dataPoints').that.is.an('array').with.length.greaterThan(0); - } + result.forEach(m => { + expect(m).to.have.property('name').that.is.a('string'); + expect(m).to.have.property('unit').that.is.a('string'); + const hasEnvelope = 'gauge' in m || 'sum' in m || 'histogram' in m; + expect(hasEnvelope).to.equal(true); + }); }); it('returns an empty array for an empty payload', () => { @@ -60,7 +62,7 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { it('only emits metrics whose source fields are present', () => { const result = extractMetrics({ libuv: { min: 10, max: 200, sum: 500, num: 5 } }, mapper); - const names = result.map(m => m.descriptor.name); + const names = result.map(m => m.name); expect(names).to.deep.equal([ 'nodejs.eventloop.delay.min', 'nodejs.eventloop.delay.max', @@ -68,11 +70,42 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { ]); }); - it('gc.duration has histogram type with count and sum in value', () => { + it('gc.duration uses histogram envelope with count and sum', () => { const result = extractMetrics({ gc: { gcPause: 1000 } }, mapper); - const gcMetric = result.find(m => m.descriptor.name === 'v8js.gc.duration'); - expect(gcMetric.type).to.equal('histogram'); - expect(gcMetric.dataPoints[0].value).to.deep.equal({ count: 1, sum: 1 }); + const gcMetric = result.find(m => m.name === 'v8js.gc.duration'); + expect(gcMetric).to.have.property('histogram'); + expect(gcMetric.histogram).to.have.property('dataPoints').with.length(1); + expect(gcMetric.histogram.dataPoints[0]).to.include({ count: '1', sum: 1 }); + }); + + it('gauge metrics use gauge envelope', () => { + const result = extractMetrics({ activeResources: { count: 18 } }, mapper); + const metric = result.find(m => m.name === 'v8js.resource.active'); + expect(metric).to.have.property('gauge'); + expect(metric.gauge.dataPoints[0]).to.have.property('asInt', 18); + }); + + it('updowncounter metrics use sum envelope with isMonotonic false', () => { + const result = extractMetrics(FULL_PAYLOAD, mapper); + const metric = result.find(m => m.name === 'v8js.memory.heap.space.available_size'); + expect(metric).to.have.property('sum'); + expect(metric.sum.isMonotonic).to.equal(false); + expect(metric.sum.aggregationTemporality).to.equal(2); + }); + + it('data-points have timeUnixNano derived from payload timestamp', () => { + const result = extractMetrics({ activeResources: { count: 5 }, timestamp: 1544712660300 }, mapper); + const metric = result.find(m => m.name === 'v8js.resource.active'); + expect(metric.gauge.dataPoints[0].timeUnixNano).to.equal(String(1544712660300 * 1e6)); + }); + + it('data-point attributes are formatted as OTLP key-value array', () => { + const result = extractMetrics({ activeResources: { count: 5 } }, mapper); + const metric = result.find(m => m.name === 'v8js.resource.active'); + const attrs = metric.gauge.dataPoints[0].attributes; + expect(attrs).to.be.an('array').with.length(1); + expect(attrs[0]).to.deep.include({ key: 'v8js.resource.type' }); + expect(attrs[0].value).to.deep.equal({ stringValue: 'all' }); }); }); }); From 3fff04dbb2eff232b2f9d5c44e18cfa25bd149f2 Mon Sep 17 00:00:00 2001 From: Arya Date: Tue, 4 Aug 2026 12:04:02 +0530 Subject: [PATCH 3/3] chore: updated --- .../src/otlpExporter/metrics/converter.js | 4 +- .../src/otlpExporter/metrics/mappers/index.js | 17 +- .../metrics/mappers/runtimeMetricsMappings.js | 144 +++++++++-------- .../src/otlpExporter/metrics/mappers/util.js | 42 ++--- .../metrics/transformers/index.js | 12 +- .../metrics/transformers/runtimeMetrics.js | 73 +-------- .../otlpExporter/metrics/transformers/util.js | 118 +++++++++++++- .../mappers/runtimeMetricsMappings_test.js | 149 +++++++++++++++--- .../transformers/runtimeMetrics_test.js | 26 ++- 9 files changed, 369 insertions(+), 216 deletions(-) diff --git a/packages/core/src/otlpExporter/metrics/converter.js b/packages/core/src/otlpExporter/metrics/converter.js index a7b2d56ff4..7a12489ad8 100644 --- a/packages/core/src/otlpExporter/metrics/converter.js +++ b/packages/core/src/otlpExporter/metrics/converter.js @@ -45,8 +45,6 @@ function convert(metrics) { // Service name resolution, it not come from first metric once it set it will be used for all metrics resolveServiceName(metrics); - const mapper = mappers.get(metrics); - // All metrics share the same resource, so we can extract the attributes from the first one const resource = transformers.resource.extractResourceAttributes(/** @type {any} */ (metricsArray[0])); @@ -57,7 +55,7 @@ function convert(metrics) { scopeMetrics: [ { scope: INSTRUMENTATION_SCOPE, - metrics: transformers.runtimeMetrics.extractMetrics(metrics, mapper) + metrics: transformers.extractMetrics(metrics, mappers.allMappings) } ] } diff --git a/packages/core/src/otlpExporter/metrics/mappers/index.js b/packages/core/src/otlpExporter/metrics/mappers/index.js index ca2a0b4e5e..8f5b7cd63f 100644 --- a/packages/core/src/otlpExporter/metrics/mappers/index.js +++ b/packages/core/src/otlpExporter/metrics/mappers/index.js @@ -4,16 +4,13 @@ 'use strict'; -const runtimeMetrics = require('./runtimeMetricsMappings'); - -/** - * @param {any} _metrics - */ -// eslint-disable-next-line no-unused-vars -function get(_metrics) { - return runtimeMetrics; -} +const runtimeMetricsMappings = require('./runtimeMetricsMappings'); module.exports = { - get + get allMappings() { + return [ + runtimeMetricsMappings + // future: httpMetricsMappings, + ]; + } }; diff --git a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js index 165872a2cb..991c8011e9 100644 --- a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js +++ b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js @@ -6,129 +6,149 @@ const ctx = require('../../common/context'); const { METRIC_TYPES, METRIC_UNITS } = require('./constants'); -const { msToSeconds, heapSpacePoints, singlePoint } = require('./util'); +const { msToSeconds, computeMean } = require('./util'); const OTLP = /** @type {any} */ (ctx.semConv); /** - * @typedef {Object} MetricDataPointMapping + * A single-value metric — maps one Instana payload field to one OTLP data point. + * + * @typedef {Object} SinglePointMapping + * @property {'single'} pointType + * @property {string} name + * @property {string} unit + * @property {string} type * @property {string} instana - * @property {Record} attributes - * @property {(value: any) => any} [transform] + * @property {Record} [attributes] + * @property {(value: any, payload: Record) => any} [transform] */ /** - * @typedef {Object} MetricMapping + * A histogram metric — maps one Instana payload field to an OTLP histogram data point + * (produces `{ count, sum }` instead of a plain number). + * + * @typedef {Object} HistogramMapping + * @property {'histogram'} pointType * @property {string} name * @property {string} unit * @property {string} type - * @property {string} instanaPrefix - * @property {(payload: Record) => Array<{attributes: Record, value: any}> | null} dataPoints + * @property {string} instana + * @property {Record} [attributes] + * @property {(value: any, payload: Record) => any} [transform] */ +/** + * A fan-out metric — iterates over a map of heap spaces and emits one data point per space. + * + * @typedef {Object} HeapSpaceMapping + * @property {'heapSpace'} pointType + * @property {string} name + * @property {string} unit + * @property {string} type + * @property {string} instana + * @property {string} field + * @property {string} attributeKey + */ + +/** + * @typedef {SinglePointMapping | HistogramMapping | HeapSpaceMapping} MetricMapping + */ + +const OTLP_V8 = OTLP.metrics.v8js; +const OTLP_NODEJS = OTLP.metrics.nodejs; + /** @type {MetricMapping[]} */ const v8Mappings = [ { - name: OTLP.metrics.v8js.GC_DURATION, + pointType: 'histogram', + name: OTLP_V8.GC_DURATION, unit: METRIC_UNITS.SECONDS, type: METRIC_TYPES.HISTOGRAM, - instanaPrefix: 'gc', - dataPoints(payload) { - const gc = payload.gc; - if (!gc || typeof gc.gcPause !== 'number') return null; - return singlePoint({ count: 1, sum: msToSeconds(gc.gcPause) }, { [OTLP.metrics.v8js.attributes.GC_TYPE]: 'all' }); - } + instana: 'gc.gcPause', + attributes: { [OTLP_V8.attributes.GC_TYPE]: 'all' }, + transform: msToSeconds }, { - name: OTLP.metrics.v8js.HEAP_SPACE_AVAILABLE_SIZE, + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_AVAILABLE_SIZE, unit: METRIC_UNITS.BYTES, type: METRIC_TYPES.UPDOWNCOUNTER, - instanaPrefix: 'heapSpaces', - dataPoints(payload) { - return heapSpacePoints(payload.heapSpaces, 'available', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); - } + instana: 'heapSpaces', + field: 'available', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME }, { - name: OTLP.metrics.v8js.HEAP_SPACE_PHYSICAL_SIZE, + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_PHYSICAL_SIZE, unit: METRIC_UNITS.BYTES, type: METRIC_TYPES.UPDOWNCOUNTER, - instanaPrefix: 'heapSpaces', - dataPoints(payload) { - return heapSpacePoints(payload.heapSpaces, 'physical', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); - } + instana: 'heapSpaces', + field: 'physical', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME }, { - name: OTLP.metrics.v8js.HEAP_SPACE_SIZE, + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_SIZE, unit: METRIC_UNITS.BYTES, type: METRIC_TYPES.UPDOWNCOUNTER, - instanaPrefix: 'heapSpaces', - dataPoints(payload) { - return heapSpacePoints(payload.heapSpaces, 'current', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); - } + instana: 'heapSpaces', + field: 'current', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME }, { - name: OTLP.metrics.v8js.HEAP_USED, + pointType: 'heapSpace', + name: OTLP_V8.HEAP_USED, unit: METRIC_UNITS.BYTES, type: METRIC_TYPES.UPDOWNCOUNTER, - instanaPrefix: 'heapSpaces', - dataPoints(payload) { - return heapSpacePoints(payload.heapSpaces, 'used', OTLP.metrics.v8js.attributes.HEAP_SPACE_NAME); - } + instana: 'heapSpaces', + field: 'used', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME }, { - name: OTLP.metrics.v8js.RESOURCE_ACTIVE, + pointType: 'single', + name: OTLP_V8.RESOURCE_ACTIVE, unit: METRIC_UNITS.RESOURCES, type: METRIC_TYPES.GAUGE, - instanaPrefix: 'activeResources', - dataPoints(payload) { - const ar = payload.activeResources; - if (!ar || typeof ar.count !== 'number') return null; - return singlePoint(ar.count, { [OTLP.metrics.v8js.attributes.RESOURCE_TYPE]: 'all' }); - } + instana: 'activeResources.count', + attributes: { [OTLP_V8.attributes.RESOURCE_TYPE]: 'all' } } ]; /** @type {MetricMapping[]} */ const nodejsMappings = [ { - name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MIN, + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MIN, unit: METRIC_UNITS.SECONDS, type: METRIC_TYPES.GAUGE, - instanaPrefix: 'libuv', - dataPoints(payload) { - const libuv = payload.libuv; - if (!libuv || typeof libuv.min !== 'number') return null; - return singlePoint(msToSeconds(libuv.min), {}); - } + instana: 'libuv.min', + attributes: {}, + transform: msToSeconds }, { - name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MAX, + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MAX, unit: METRIC_UNITS.SECONDS, type: METRIC_TYPES.GAUGE, - instanaPrefix: 'libuv', - dataPoints(payload) { - const libuv = payload.libuv; - if (!libuv || typeof libuv.max !== 'number') return null; - return singlePoint(msToSeconds(libuv.max), {}); - } + instana: 'libuv.max', + attributes: {}, + transform: msToSeconds }, { - name: OTLP.metrics.nodejs.EVENTLOOP_DELAY_MEAN, + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MEAN, unit: METRIC_UNITS.SECONDS, type: METRIC_TYPES.GAUGE, - instanaPrefix: 'libuv', - dataPoints(payload) { - const libuv = payload.libuv; - if (!libuv || typeof libuv.sum !== 'number' || typeof libuv.num !== 'number' || libuv.num === 0) return null; - return singlePoint(msToSeconds(libuv.sum / libuv.num), {}); - } + instana: 'libuv', + attributes: {}, + transform: computeMean } ]; diff --git a/packages/core/src/otlpExporter/metrics/mappers/util.js b/packages/core/src/otlpExporter/metrics/mappers/util.js index 00d3b4172a..79313014c3 100644 --- a/packages/core/src/otlpExporter/metrics/mappers/util.js +++ b/packages/core/src/otlpExporter/metrics/mappers/util.js @@ -5,44 +5,36 @@ 'use strict'; /** - * - * @param {number} ms - * @returns {number} + * @param {any} ms + * @returns {number | undefined} */ function msToSeconds(ms) { + if (typeof ms !== 'number') return undefined; return ms / 1000; } /** - * @param {Record} heapSpaces - * @param {string}field - * @param {string} attributeKey - * @returns {Array<{attributes: Record, value: number}> | null} + * @param {any} libuv - The `libuv` sub-object from the metrics payload + * @returns {number | undefined} */ -function heapSpacePoints(heapSpaces, field, attributeKey) { - if (!heapSpaces || typeof heapSpaces !== 'object') return null; - - const points = Object.entries(heapSpaces) - .filter(([, space]) => space && typeof space[field] === 'number') - .map(([name, space]) => ({ - attributes: { [attributeKey]: name }, - value: space[field] - })); - - return points.length ? points : null; +function computeMean(libuv) { + if (!libuv || typeof libuv.sum !== 'number' || typeof libuv.num !== 'number' || libuv.num === 0) { + return undefined; + } + return msToSeconds(libuv.sum / libuv.num); } /** - * @param {any} value - * @param {Record} attributes - * @returns {Array<{attributes: Record, value: any}>} + * @param {Record} payload + * @param {string} path + * @returns {any} */ -function singlePoint(value, attributes) { - return [{ attributes, value }]; +function resolvePath(payload, path) { + return path.split('.').reduce((obj, key) => (obj != null ? obj[key] : undefined), payload); } module.exports = { msToSeconds, - heapSpacePoints, - singlePoint + computeMean, + resolvePath }; diff --git a/packages/core/src/otlpExporter/metrics/transformers/index.js b/packages/core/src/otlpExporter/metrics/transformers/index.js index 6d8abf5898..1be44f816c 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/index.js +++ b/packages/core/src/otlpExporter/metrics/transformers/index.js @@ -7,7 +7,17 @@ const resource = require('../../common/transformers/resource'); const runtimeMetrics = require('./runtimeMetrics'); +/** + * @param {Record} metricsPayload + * @param {Array<{ metricMappings: any[] }>} allMappings + * @returns {Array>} OTLP metric objects + */ +function extractMetrics(metricsPayload, allMappings) { + return allMappings.flatMap(mapper => runtimeMetrics.extractMetrics(metricsPayload, mapper)); +} + module.exports = { resource, - runtimeMetrics + runtimeMetrics, + extractMetrics }; diff --git a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js index cdd455c685..c661e1c186 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js +++ b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js @@ -4,79 +4,16 @@ 'use strict'; -const { METRIC_TYPES } = require('../mappers/constants'); -const { buildDataPoints } = require('./util'); +const { extractMappedMetrics } = require('./util'); /** - * @typedef {import('../mappers/runtimeMetricsMappings').MetricMapping} MetricMapping - */ - -const OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE = 2; - -/** - * @param {string} type - * @param {Array>} dataPoints - * @returns {Record} OTLP - */ -function buildMetricEnvelope(type, dataPoints) { - switch (type) { - case METRIC_TYPES.UPDOWNCOUNTER: - return { - sum: { - aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, - isMonotonic: false, - dataPoints - } - }; - - case METRIC_TYPES.HISTOGRAM: - return { - histogram: { - aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, - dataPoints - } - }; - - case METRIC_TYPES.GAUGE: - default: - return { - gauge: { - dataPoints - } - }; - } -} - -/** - * Converts Instana runtime metrics into OTLP metric objects. - * * @param {Record} metricsPayload - * @param {{ metricMappings: MetricMapping[] }} mapper - * @returns {Array>} OTLP + * @param {{ metricMappings: import('../mappers/runtimeMetricsMappings').MetricMapping[] }} mapper + * @returns {Array>} OTLP metric objects */ function extractMetrics(metricsPayload, mapper) { - if (!metricsPayload || !Array.isArray(mapper?.metricMappings)) { - return []; - } - - const timeUnixNano = (metricsPayload.timestamp ?? Date.now()) * 1e6; - - return mapper.metricMappings.reduce((metrics, mapping) => { - const rawDataPoints = mapping.dataPoints(metricsPayload); - - if (!rawDataPoints) { - return metrics; - } - - // @ts-ignore - metrics.push({ - name: mapping.name, - unit: mapping.unit, - ...buildMetricEnvelope(mapping.type, buildDataPoints(rawDataPoints, timeUnixNano)) - }); - - return metrics; - }, []); + const timeUnixNano = (metricsPayload?.timestamp ?? Date.now()) * 1e6; + return extractMappedMetrics(metricsPayload, mapper, timeUnixNano); } module.exports = { diff --git a/packages/core/src/otlpExporter/metrics/transformers/util.js b/packages/core/src/otlpExporter/metrics/transformers/util.js index 6fc7877dee..110c78f196 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/util.js +++ b/packages/core/src/otlpExporter/metrics/transformers/util.js @@ -4,6 +4,13 @@ 'use strict'; +const { METRIC_TYPES } = require('../mappers/constants'); +const { resolvePath } = require('../mappers/util'); + +/** + * @typedef {import('../mappers/runtimeMetricsMappings').MetricMapping} MetricMapping + */ + /** * @param {Record} attributes * @returns {Array<{ key: string, value: Record }>} @@ -27,10 +34,6 @@ function formatAttributes(attributes) { } /** - * Serialises the raw data-points produced by a mapper into OTLP data-point - * objects, adding `timeUnixNano` and converting the `attributes` map into the - * OTLP key-value array format. - * * @param {Array<{ attributes: Record, value: any }>} rawPoints * @param {number} timeUnixNano * @returns {Array>} @@ -44,7 +47,6 @@ function buildDataPoints(rawPoints, timeUnixNano) { if (type === 'number') { numericField = Number.isInteger(val) ? { asInt: val } : { asDouble: val }; } else if (val !== null && type === 'object' && ('count' in val || 'sum' in val)) { - // histogram value shape: { count, sum } numericField = { count: String(val.count), sum: val.sum }; } else { numericField = { asDouble: Number(val) }; @@ -58,7 +60,111 @@ function buildDataPoints(rawPoints, timeUnixNano) { }); } +const OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE = 2; + +/** + * @param {string} type - One of METRIC_TYPES + * @param {Array>} dataPoints + * @returns {Record} + */ +function buildMetricEnvelope(type, dataPoints) { + switch (type) { + case METRIC_TYPES.UPDOWNCOUNTER: + return { + sum: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + isMonotonic: false, + dataPoints + } + }; + + case METRIC_TYPES.HISTOGRAM: + return { + histogram: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + dataPoints + } + }; + + case METRIC_TYPES.GAUGE: + default: + return { gauge: { dataPoints } }; + } +} + +/** + * Supported `pointType` values: + * 'single' — scalar field → one data point + * 'histogram' — scalar field → one histogram data point ({ count, sum }) + * 'heapSpace' — object map → one data point per entry + * + * @param {MetricMapping} mapping + * @param {Record} payload + * @returns {Array<{ attributes: Record, value: any }> | null} + */ +function resolveDataPoints(mapping, payload) { + if (mapping.pointType === 'heapSpace') { + const heapSpaces = resolvePath(payload, mapping.instana); + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + + const points = Object.entries(heapSpaces) + .filter(([, space]) => space && typeof space[mapping.field] === 'number') + .map(([name, space]) => ({ + attributes: { [mapping.attributeKey]: name }, + value: space[mapping.field] + })); + + return points.length ? points : null; + } + + const raw = resolvePath(payload, mapping.instana); + + if (!mapping.transform && typeof raw !== 'number') return null; + + const value = mapping.transform ? mapping.transform(raw, payload) : raw; + + if (value === undefined || value === null) return null; + if (typeof value === 'number' && isNaN(value)) return null; + + const attributes = mapping.attributes ?? {}; + + if (mapping.pointType === 'histogram') { + return [{ attributes, value: { count: 1, sum: value } }]; + } + + return [{ attributes, value }]; +} + +/** + * @param {Record} metricsPayload + * @param {{ metricMappings: MetricMapping[] }} mapper + * @param {number} timeUnixNano + * @returns {Array>} OTLP metric objects + */ +function extractMappedMetrics(metricsPayload, mapper, timeUnixNano) { + if (!metricsPayload || !Array.isArray(mapper?.metricMappings)) { + return []; + } + + return mapper.metricMappings.reduce((/** @type {any[]} */ acc, mapping) => { + const rawDataPoints = resolveDataPoints(mapping, metricsPayload); + + if (rawDataPoints) { + acc.push({ + name: mapping.name, + unit: mapping.unit, + ...buildMetricEnvelope(mapping.type, buildDataPoints(rawDataPoints, timeUnixNano)) + }); + } + + return acc; + }, []); +} + module.exports = { formatAttributes, - buildDataPoints + buildDataPoints, + buildMetricEnvelope, + resolveDataPoints, + extractMappedMetrics }; diff --git a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js index 1695d4adf2..ed0bfa9f37 100644 --- a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js +++ b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js @@ -11,6 +11,8 @@ const V8 = MAPPINGS.metrics.v8js; const NODEJS = MAPPINGS.metrics.nodejs; const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); +const { resolvePath, msToSeconds, computeMean } = require('../../../../src/otlpExporter/metrics/mappers/util'); +const { resolveDataPoints } = require('../../../../src/otlpExporter/metrics/transformers/util'); const FULL_PAYLOAD = { gc: { gcPause: 414 }, @@ -34,6 +36,16 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { it('exports 9 mapping entries', () => { expect(mapper.metricMappings).to.have.length(9); }); + + it('every mapping declares pointType, name, unit, type and instana', () => { + mapper.metricMappings.forEach(m => { + expect(m).to.have.property('pointType').that.is.a('string'); + expect(m).to.have.property('name').that.is.a('string'); + expect(m).to.have.property('unit').that.is.a('string'); + expect(m).to.have.property('type').that.is.a('string'); + expect(m).to.have.property('instana').that.is.a('string'); + }); + }); }); describe('v8js.gc.duration', () => { @@ -45,21 +57,34 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { it('has correct descriptor metadata', () => { expect(mapping.unit).to.equal('s'); expect(mapping.type).to.equal('histogram'); + expect(mapping.pointType).to.equal('histogram'); }); - it('converts gcPause ms → seconds as Histogram sum', () => { - const points = mapping.dataPoints({ gc: { gcPause: 414 } }); + it('declares instana path gc.gcPause', () => { + expect(mapping.instana).to.equal('gc.gcPause'); + }); + + it('declares gc.type = "all" attribute', () => { + expect(mapping.attributes).to.deep.include({ [V8.attributes.GC_TYPE]: 'all' }); + }); + + it('transform converts ms → seconds', () => { + expect(mapping.transform(414)).to.equal(0.414); + }); + + it('resolves to a histogram data point from a full payload', () => { + const points = resolveDataPoints(mapping, { gc: { gcPause: 414 } }); expect(points).to.deep.equal([ { attributes: { [V8.attributes.GC_TYPE]: 'all' }, value: { count: 1, sum: 0.414 } } ]); }); it('returns null when gc is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); it('returns null when gcPause is not a number', () => { - expect(mapping.dataPoints({ gc: { gcPause: null } })).to.be.null; + expect(resolveDataPoints(mapping, { gc: { gcPause: null } })).to.be.null; }); }); @@ -72,10 +97,16 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { it('has correct descriptor metadata', () => { expect(mapping.unit).to.equal('By'); expect(mapping.type).to.equal('updowncounter'); + expect(mapping.pointType).to.equal('heapSpace'); + }); + + it('declares field = "available" and correct attributeKey', () => { + expect(mapping.field).to.equal('available'); + expect(mapping.attributeKey).to.equal(V8.attributes.HEAP_SPACE_NAME); }); it('emits one data-point per space that has available', () => { - const points = mapping.dataPoints(FULL_PAYLOAD); + const points = resolveDataPoints(mapping, FULL_PAYLOAD); expect(points).to.deep.equal([ { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 5972864 }, { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 25165824 } @@ -83,11 +114,11 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { }); it('returns null when heapSpaces is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); it('returns null when no space has available', () => { - expect(mapping.dataPoints({ heapSpaces: { x: { current: 1 } } })).to.be.null; + expect(resolveDataPoints(mapping, { heapSpaces: { x: { current: 1 } } })).to.be.null; }); }); @@ -97,8 +128,12 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(V8.HEAP_SPACE_PHYSICAL_SIZE); }); + it('declares field = "physical"', () => { + expect(mapping.field).to.equal('physical'); + }); + it('emits one data-point per space that has physical', () => { - const points = mapping.dataPoints(FULL_PAYLOAD); + const points = resolveDataPoints(mapping, FULL_PAYLOAD); expect(points).to.deep.equal([ { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 27901952 }, { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 17039360 } @@ -106,7 +141,7 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { }); it('returns null when heapSpaces is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); }); @@ -116,8 +151,12 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(V8.HEAP_SPACE_SIZE); }); + it('declares field = "current"', () => { + expect(mapping.field).to.equal('current'); + }); + it('emits one data-point per space that has current', () => { - const points = mapping.dataPoints(FULL_PAYLOAD); + const points = resolveDataPoints(mapping, FULL_PAYLOAD); expect(points).to.deep.equal([ { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 6291456 }, { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16859136 } @@ -125,7 +164,7 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { }); it('returns null when heapSpaces is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); }); @@ -135,8 +174,12 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(V8.HEAP_USED); }); + it('declares field = "used"', () => { + expect(mapping.field).to.equal('used'); + }); + it('emits one data-point per space that has used', () => { - const points = mapping.dataPoints(FULL_PAYLOAD); + const points = resolveDataPoints(mapping, FULL_PAYLOAD); expect(points).to.deep.equal([ { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 10622592 }, { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16309064 } @@ -144,7 +187,7 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { }); it('returns null when heapSpaces is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); }); @@ -157,19 +200,28 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { it('has correct descriptor metadata', () => { expect(mapping.unit).to.equal('{resource}'); expect(mapping.type).to.equal('gauge'); + expect(mapping.pointType).to.equal('single'); + }); + + it('declares instana path activeResources.count', () => { + expect(mapping.instana).to.equal('activeResources.count'); + }); + + it('declares resource.type = "all" attribute', () => { + expect(mapping.attributes).to.deep.include({ [V8.attributes.RESOURCE_TYPE]: 'all' }); }); it('maps activeResources.count with resource.type = "all"', () => { - const points = mapping.dataPoints({ activeResources: { count: 18 } }); + const points = resolveDataPoints(mapping, { activeResources: { count: 18 } }); expect(points).to.deep.equal([{ attributes: { [V8.attributes.RESOURCE_TYPE]: 'all' }, value: 18 }]); }); it('returns null when activeResources is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); it('returns null when count is not a number', () => { - expect(mapping.dataPoints({ activeResources: { count: null } })).to.be.null; + expect(resolveDataPoints(mapping, { activeResources: { count: null } })).to.be.null; }); }); @@ -179,22 +231,27 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MIN); }); + it('declares instana path libuv.min and transform = msToSeconds', () => { + expect(mapping.instana).to.equal('libuv.min'); + expect(mapping.transform).to.equal(msToSeconds); + }); + it('converts libuv.min ms → seconds', () => { - const points = mapping.dataPoints({ libuv: { min: 0 } }); + const points = resolveDataPoints(mapping, { libuv: { min: 0 } }); expect(points).to.deep.equal([{ attributes: {}, value: 0 }]); }); it('converts non-zero min', () => { - const points = mapping.dataPoints({ libuv: { min: 5000 } }); + const points = resolveDataPoints(mapping, { libuv: { min: 5000 } }); expect(points[0].value).to.equal(5); }); it('returns null when libuv is missing', () => { - expect(mapping.dataPoints({})).to.be.null; + expect(resolveDataPoints(mapping, {})).to.be.null; }); it('returns null when min is not a number', () => { - expect(mapping.dataPoints({ libuv: { min: null } })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: { min: null } })).to.be.null; }); }); @@ -204,13 +261,18 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MAX); }); + it('declares instana path libuv.max and transform = msToSeconds', () => { + expect(mapping.instana).to.equal('libuv.max'); + expect(mapping.transform).to.equal(msToSeconds); + }); + it('converts libuv.max ms → seconds', () => { - const points = mapping.dataPoints({ libuv: { max: 582 } }); + const points = resolveDataPoints(mapping, { libuv: { max: 582 } }); expect(points).to.deep.equal([{ attributes: {}, value: 0.582 }]); }); it('returns null when max is absent', () => { - expect(mapping.dataPoints({ libuv: {} })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: {} })).to.be.null; }); }); @@ -220,18 +282,53 @@ describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MEAN); }); + it('declares instana path libuv and transform = computeMean', () => { + expect(mapping.instana).to.equal('libuv'); + expect(mapping.transform).to.equal(computeMean); + }); + it('derives mean from sum / num and converts ms → seconds', () => { - const points = mapping.dataPoints({ libuv: { sum: 4200, num: 42 } }); + const points = resolveDataPoints(mapping, { libuv: { sum: 4200, num: 42 } }); expect(points).to.deep.equal([{ attributes: {}, value: 0.1 }]); }); it('returns null when num is 0 (avoids division by zero)', () => { - expect(mapping.dataPoints({ libuv: { sum: 100, num: 0 } })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: { sum: 100, num: 0 } })).to.be.null; }); it('returns null when sum or num is missing', () => { - expect(mapping.dataPoints({ libuv: { sum: 100 } })).to.be.null; - expect(mapping.dataPoints({ libuv: { num: 5 } })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: { sum: 100 } })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: { num: 5 } })).to.be.null; + }); + }); + + describe('mappers/util helpers', () => { + describe('resolvePath', () => { + it('resolves a nested dot path', () => { + expect(resolvePath({ a: { b: 42 } }, 'a.b')).to.equal(42); + }); + + it('returns undefined for missing segments', () => { + expect(resolvePath({}, 'a.b')).to.be.undefined; + }); + + it('returns undefined when an intermediate segment is null', () => { + expect(resolvePath({ a: null }, 'a.b')).to.be.undefined; + }); + }); + + describe('computeMean', () => { + it('returns mean in seconds', () => { + expect(computeMean({ sum: 4200, num: 42 })).to.equal(0.1); + }); + + it('returns undefined when num is 0', () => { + expect(computeMean({ sum: 100, num: 0 })).to.be.undefined; + }); + + it('returns undefined when libuv is null', () => { + expect(computeMean(null)).to.be.undefined; + }); }); }); }); diff --git a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js index 55845b8e65..838c2b9db5 100644 --- a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js +++ b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js @@ -7,7 +7,7 @@ const expect = require('chai').expect; const { extractMetrics } = require('../../../../src/otlpExporter/metrics/transformers/runtimeMetrics'); -const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); +const runtimeMetricsMappings = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); const FULL_PAYLOAD = { gc: { gcPause: 414 }, @@ -23,7 +23,7 @@ const FULL_PAYLOAD = { describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { describe('extractMetrics', () => { it('produces all 9 metrics from a full payload', () => { - const result = extractMetrics(FULL_PAYLOAD, mapper); + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); const names = result.map(m => m.name); expect(names).to.deep.equal([ 'v8js.gc.duration', @@ -39,7 +39,7 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { }); it('each metric has name, unit and the correct OTLP type envelope', () => { - const result = extractMetrics(FULL_PAYLOAD, mapper); + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); result.forEach(m => { expect(m).to.have.property('name').that.is.a('string'); expect(m).to.have.property('unit').that.is.a('string'); @@ -49,19 +49,15 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { }); it('returns an empty array for an empty payload', () => { - expect(extractMetrics({}, mapper)).to.deep.equal([]); + expect(extractMetrics({}, runtimeMetricsMappings)).to.deep.equal([]); }); it('returns an empty array for null payload', () => { - expect(extractMetrics(null, mapper)).to.deep.equal([]); - }); - - it('returns an empty array for null mapper', () => { - expect(extractMetrics(FULL_PAYLOAD, null)).to.deep.equal([]); + expect(extractMetrics(null, runtimeMetricsMappings)).to.deep.equal([]); }); it('only emits metrics whose source fields are present', () => { - const result = extractMetrics({ libuv: { min: 10, max: 200, sum: 500, num: 5 } }, mapper); + const result = extractMetrics({ libuv: { min: 10, max: 200, sum: 500, num: 5 } }, runtimeMetricsMappings); const names = result.map(m => m.name); expect(names).to.deep.equal([ 'nodejs.eventloop.delay.min', @@ -71,7 +67,7 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { }); it('gc.duration uses histogram envelope with count and sum', () => { - const result = extractMetrics({ gc: { gcPause: 1000 } }, mapper); + const result = extractMetrics({ gc: { gcPause: 1000 } }, runtimeMetricsMappings); const gcMetric = result.find(m => m.name === 'v8js.gc.duration'); expect(gcMetric).to.have.property('histogram'); expect(gcMetric.histogram).to.have.property('dataPoints').with.length(1); @@ -79,14 +75,14 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { }); it('gauge metrics use gauge envelope', () => { - const result = extractMetrics({ activeResources: { count: 18 } }, mapper); + const result = extractMetrics({ activeResources: { count: 18 } }, runtimeMetricsMappings); const metric = result.find(m => m.name === 'v8js.resource.active'); expect(metric).to.have.property('gauge'); expect(metric.gauge.dataPoints[0]).to.have.property('asInt', 18); }); it('updowncounter metrics use sum envelope with isMonotonic false', () => { - const result = extractMetrics(FULL_PAYLOAD, mapper); + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); const metric = result.find(m => m.name === 'v8js.memory.heap.space.available_size'); expect(metric).to.have.property('sum'); expect(metric.sum.isMonotonic).to.equal(false); @@ -94,13 +90,13 @@ describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { }); it('data-points have timeUnixNano derived from payload timestamp', () => { - const result = extractMetrics({ activeResources: { count: 5 }, timestamp: 1544712660300 }, mapper); + const result = extractMetrics({ activeResources: { count: 5 }, timestamp: 1544712660300 }, runtimeMetricsMappings); const metric = result.find(m => m.name === 'v8js.resource.active'); expect(metric.gauge.dataPoints[0].timeUnixNano).to.equal(String(1544712660300 * 1e6)); }); it('data-point attributes are formatted as OTLP key-value array', () => { - const result = extractMetrics({ activeResources: { count: 5 } }, mapper); + const result = extractMetrics({ activeResources: { count: 5 } }, runtimeMetricsMappings); const metric = result.find(m => m.name === 'v8js.resource.active'); const attrs = metric.gauge.dataPoints[0].attributes; expect(attrs).to.be.an('array').with.length(1);