diff --git a/example-apps/collector/.env b/example-apps/collector/.env index f94dabfe58..00dccf7bf8 100644 --- a/example-apps/collector/.env +++ b/example-apps/collector/.env @@ -1,6 +1,6 @@ # All default settings defined here can be overridden by environment variables. -# MODE=npm +MODE=local APP_PORT=2807 SENSOR_ENABLED=true TRACING_ENABLED=true diff --git a/example-apps/collector/src/index.js b/example-apps/collector/src/index.js index f2f27aa497..4aec420fd6 100644 --- a/example-apps/collector/src/index.js +++ b/example-apps/collector/src/index.js @@ -19,6 +19,9 @@ if (config.mode === 'npm') { packageToRequire = '@instana/collector'; } +process.env.INSTANA_METRICS_TRANSMISSION_DELAY = 5000; +process.env.INSTANA_OTLP_FORMAT = 'true'; + if (config.collectorEnabled) { console.log(`enabling @instana/collector (requiring ${packageToRequire})`); require(packageToRequire)({ diff --git a/packages/collector/src/agentConnection.js b/packages/collector/src/agentConnection.js index 7ca47d69c5..a45a6eee9a 100644 --- a/packages/collector/src/agentConnection.js +++ b/packages/collector/src/agentConnection.js @@ -10,7 +10,7 @@ const pathUtil = require('path'); const circularReferenceRemover = require('./util/removeCircular'); const agentOpts = require('./agent/opts'); const cmdline = require('./cmdline'); - +const otlpTransformer = require('./otlpTransformer'); /** @typedef {import('@instana/core/src/core').InstanaBaseSpan} InstanaBaseSpan */ /** @type {import('@instana/core/src/core').GenericLogger} */ @@ -307,20 +307,55 @@ function checkWhetherResponseForPathIsOkay(path, cb) { exports.sendMetrics = function sendMetrics(data, cb) { cb = util.atMostOnce('callback for sendMetrics', cb); - sendData(`/com.instana.plugin.nodejs.${pidStore.pid}`, data, (err, body) => { + // Zeige nur die ersten 2 Keys für Debugging + const dataKeys = Object.keys(data); + const firstTwoKeys = {}; + for (let i = 0; i < Math.min(2, dataKeys.length); i++) { + firstTwoKeys[dataKeys[i]] = data[dataKeys[i]]; + } + + logger.debug(`sendMetrics called with data (first 2 keys): ${JSON.stringify(firstTwoKeys)}`); + + // Transform Instana metrics to OTLP format + const otlpMetrics = otlpTransformer.transformMetrics(data); + + // Zeige nur die ersten 2 Metriken für Debugging + let otlpPreview = otlpMetrics; + if (otlpMetrics.resourceMetrics && otlpMetrics.resourceMetrics.length > 0) { + const firstResource = otlpMetrics.resourceMetrics[0]; + if (firstResource.scopeMetrics && firstResource.scopeMetrics.length > 0) { + const metrics = firstResource.scopeMetrics[0].metrics; + if (metrics && metrics.length > 2) { + otlpPreview = { + resourceMetrics: [ + { + ...firstResource, + scopeMetrics: [ + { + ...firstResource.scopeMetrics[0], + metrics: metrics.slice(0, 2) + } + ] + } + ], + totalMetrics: metrics.length + }; + } + } + } + + logger.debug(`Transformed to OTLP (first 2 metrics) ${JSON.stringify(otlpPreview)}`); + + // Send directly without using sendData (which would transform again) + sendOtlpData('/v1/metrics', otlpMetrics, err => { if (err) { + logger.error('Error sending metrics:', err); cb(err, null); } else { - try { - // 2016-09-11 - // Older sensor versions will not repond with a JSON - // structure. Support a smooth update path. - body = JSON.parse(body); - } catch (e) { - body = []; - } - - cb(null, body); + logger.debug('Metrics sent successfully'); + // OTLP endpoints don't return requests like the old Instana endpoint + // Always return empty array for compatibility + cb(null, []); } }); }; @@ -344,7 +379,7 @@ exports.sendSpans = function sendSpans(spans, cb) { cb(err); }); - sendData(`/com.instana.plugin.nodejs/traces.${pidStore.pid}`, spans, callback, true); + sendData('/v1/traces', spans, callback, true); }; /** @@ -425,7 +460,8 @@ exports.sendTracingMetricsToAgent = function sendTracingMetricsToAgent(tracingMe cb(err); }); - sendData('/tracermetrics', tracingMetrics, callback); + // sendData('/tracermetrics', tracingMetrics, callback); + cb(); }; /** @@ -437,8 +473,13 @@ exports.sendTracingMetricsToAgent = function sendTracingMetricsToAgent(tracingMe */ function sendData(path, data, cb, ignore404 = false) { cb = util.atMostOnce(`callback for sendData: ${path}`, cb); + console.log(JSON.stringify(data)); + // Transform Instana format to OTLP format + const otlpFormat = otlpTransformer(data); + + console.log(JSON.stringify(otlpFormat)); - const payloadAsString = JSON.stringify(data, circularReferenceRemover()); + const payloadAsString = JSON.stringify(otlpFormat, circularReferenceRemover()); if (typeof logger.trace === 'function') { logger.trace(`Sending data to ${path}.`); } else { @@ -455,7 +496,7 @@ function sendData(path, data, cb, ignore404 = false) { const req = http.request( { host: agentOpts.host, - port: agentOpts.port, + port: 4318, path, method: 'POST', agent: http.agent, @@ -465,24 +506,26 @@ function sendData(path, data, cb, ignore404 = false) { } }, res => { - if (res.statusCode < 200 || res.statusCode >= 300) { - if (res.statusCode !== 404 || !ignore404) { - const statusCodeError = new Error( - `Failed to send data to agent via POST ${path}. Got status code ${res.statusCode}.` - ); - // @ts-ignore - statusCodeError.statusCode = res.statusCode; - cb(statusCodeError); - return; - } - } - res.setEncoding('utf8'); let responseBody = ''; res.on('data', chunk => { responseBody += chunk; }); res.on('end', () => { + console.log(responseBody); + + if (res.statusCode < 200 || res.statusCode >= 300) { + if (res.statusCode !== 404 || !ignore404) { + const statusCodeError = new Error( + `Failed to send data to agent via POST ${path}. Got status code ${res.statusCode}.` + ); + // @ts-ignore + statusCodeError.statusCode = res.statusCode; + cb(statusCodeError); + return; + } + } + cb(null, responseBody); }); } @@ -509,6 +552,86 @@ function sendData(path, data, cb, ignore404 = false) { req.end(); } +/** + * Sendet bereits transformierte OTLP-Daten an den Agent + * @param {string} path - API path + * @param {Object} otlpData - Already transformed OTLP data + * @param {(...args: *) => *} cb - Callback + * @param {boolean} [ignore404] + */ +function sendOtlpData(path, otlpData, cb, ignore404 = false) { + cb = util.atMostOnce(`callback for sendOtlpData: ${path}`, cb); + + const payloadAsString = JSON.stringify(otlpData, circularReferenceRemover()); + if (typeof logger.trace === 'function') { + logger.trace(`Sending OTLP data to ${path}.`); + } else { + logger.debug(`Sending OTLP data to ${path}, ${agentOpts}`); + } + + // Convert payload to a buffer to correctly identify content-length ahead of time. + const payload = Buffer.from(payloadAsString, 'utf8'); + if (payload.length > maxContentLength) { + const error = new PayloadTooLargeError(`Request payload is too large. Will not send data to agent. (POST ${path})`); + return setImmediate(cb.bind(null, error)); + } + + const req = http.request( + { + host: agentOpts.host, + port: 4318, + path, + method: 'POST', + agent: http.agent, + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Content-Length': payload.length + } + }, + res => { + res.setEncoding('utf8'); + let responseBody = ''; + res.on('data', chunk => { + responseBody += chunk; + }); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + if (ignore404 && res.statusCode === 404) { + return cb(null, responseBody); + } + return cb( + new Error( + `Failed to send data to agent via POST ${path}. ` + + `Got status code ${res.statusCode}. Response: ${responseBody}` + ), + responseBody + ); + } + cb(null, responseBody); + }); + } + ); + + req.setTimeout(agentOpts.requestTimeout, function onTimeout() { + if (req.destroyed) { + return; + } + + req.destroy(new Error(`Sending data to agent via POST ${path}. Request timeout.`)); + }); + + req.on('error', err => { + if (req.destroyed) { + return; + } + + cb(new Error(`Send OTLP data to agent via POST ${path}. Request failed: ${err.message}`)); + }); + + req.write(payload); + req.end(); +} + exports.isConnected = function () { return isConnected; }; diff --git a/packages/collector/src/otlpTransformer.js b/packages/collector/src/otlpTransformer.js new file mode 100644 index 0000000000..28e74a2647 --- /dev/null +++ b/packages/collector/src/otlpTransformer.js @@ -0,0 +1,532 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +// Gespeicherte Resource-Informationen für Metrics (wenn kein "from" Feld vorhanden) +let cachedHostId = null; +let cachedPid = null; + +/** + * Setzt die Host-ID für Resource Attributes + * @param {string} hostId - Host ID + */ +function setHostId(hostId) { + cachedHostId = hostId; +} + +/** + * Setzt die PID für Resource Attributes + * @param {string|number} pid - Process ID + */ +function setPid(pid) { + cachedPid = String(pid); +} + +/** + * Transformiert Instana Traces Format zu OpenTelemetry Format + * + * OTEL Format Beispiel: + * { + * "resourceSpans": [{ + * "resource": { + * "attributes": [ + * {"key": "service.name", "value": {"stringValue": "demoService"}}, + * {"key": "process.pid", "value": {"intValue": 12345}}, + * {"key": "host.name", "value": {"stringValue": "My Fancy Host"}} + * ] + * }, + * "scopeSpans": [{ + * "scope": { + * "name": "@instana/collector", + * "version": "1.0.0" + * }, + * "spans": [{ + * "traceId": "0a0b0c0d010203040506070809008081", + * "spanId": "010203040a0b0c0d", + * "parentSpanId": "0d0c0b0a04030201", + * "name": "some span", + * "kind": 3, + * "startTimeUnixNano": "1775732779960000000", + * "endTimeUnixNano": "1775732779969000000", + * "attributes": [ + * {"key": "http.method", "value": {"stringValue": "GET"}}, + * {"key": "http.status_code", "value": {"intValue": 200}}, + * {"key": "http.url", "value": {"stringValue": "/"}} + * ], + * "status": {"code": 1} + * }] + * }] + * }] + * } + * + * INSTANA Format Beispiel: + * [{ + * "t": "b94dae370181cbd5", // trace ID + * "s": "3c84e4b658761152", // span ID + * "p": "parent_span_id", // parent span ID (optional) + * "n": "node.http.server", // span name + * "k": 1, // span kind (1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER, 5=INTERNAL) + * "f": { // from (resource attributes) + * "e": "74662", // entity ID + * "h": "7e:0d:24:ff:fe:aa:33:af" // host ID + * }, + * "ec": 0, // error count + * "ts": 1775729099820, // timestamp in milliseconds + * "d": 8, // duration in milliseconds + * "stack": [], + * "data": { // span attributes + * "http": { + * "path_tpl": "/", + * "status": 304, + * "method": "GET", + * "url": "/", + * "host": "localhost:2807" + * } + * } + * }] + */ + +/** + * OTEL Metrics Format Beispiel: + * { + * "resourceMetrics": [{ + * "resource": { + * "attributes": [ + * {"key": "service.name", "value": {"stringValue": "metricsService"}}, + * {"key": "process.pid", "value": {"intValue": 4711}}, + * {"key": "host.name", "value": {"stringValue": "My Lame Host"}} + * ] + * }, + * "scopeMetrics": [{ + * "scope": { + * "name": "instrumentationScope", + * "version": "13.2" + * }, + * "metrics": [{ + * "name": "sumMetricName", + * "sum": { + * "dataPoints": [{ + * "asDouble": 42.42 + * }] + * } + * }] + * }] + * }] + * } + */ + +/** + * Konvertiert Instana Span Kind zu OTEL Span Kind + * @param {number} instanaKind - Instana span kind + * @returns {number} OTEL span kind + */ +function convertSpanKind(instanaKind) { + // Instana: 1=ENTRY/SERVER, 2=EXIT/CLIENT, 3=INTERMEDIATE/INTERNAL + // OTEL: 0=UNSPECIFIED, 1=INTERNAL, 2=SERVER, 3=CLIENT, 4=PRODUCER, 5=CONSUMER + switch (instanaKind) { + case 1: // ENTRY -> SERVER + return 2; + case 2: // EXIT -> CLIENT + return 3; + case 3: // INTERMEDIATE -> INTERNAL + return 1; + default: + return 0; // UNSPECIFIED + } +} + +/** + * Konvertiert Millisekunden zu Nanosekunden (als String) + * @param {number} ms - Millisekunden + * @returns {string} Nanosekunden als String + */ +function msToNano(ms) { + return String(ms * 1000000); +} + +/** + * Erstellt OTEL Attribute aus Instana Span Data + * @param {Object} data - Instana span data + * @returns {Array} OTEL attributes array + */ +function createAttributes(data) { + const attributes = []; + + if (!data) { + return attributes; + } + + // HTTP Attribute + if (data.http) { + if (data.http.method) { + attributes.push({ + key: 'http.method', + value: { stringValue: data.http.method } + }); + } + if (data.http.status) { + attributes.push({ + key: 'http.status_code', + value: { intValue: data.http.status } + }); + } + if (data.http.url) { + attributes.push({ + key: 'http.url', + value: { stringValue: data.http.url } + }); + } + if (data.http.host) { + attributes.push({ + key: 'http.host', + value: { stringValue: data.http.host } + }); + } + if (data.http.path_tpl) { + attributes.push({ + key: 'http.target', + value: { stringValue: data.http.path_tpl } + }); + } + } + + // Weitere Datenfelder können hier hinzugefügt werden + // z.B. data.db, data.service, etc. + + return attributes; +} + +/** + * Erstellt Resource Attributes aus Instana "from" Feld + * @param {Object} from - Instana from object + * @returns {Array} OTEL resource attributes + */ +function createResourceAttributes(from) { + const attributes = []; + + // Standard OTEL Resource Attributes + attributes.push({ + key: 'telemetry.sdk.language', + value: { stringValue: 'nodejs' } + }); + + attributes.push({ + key: 'telemetry.sdk.name', + value: { stringValue: '@instana/collector' } + }); + + // Service Name - verwende process.title oder einen Default + const serviceName = process.env.SERVICE_NAME; + attributes.push({ + key: 'service.name', + value: { stringValue: serviceName } + }); + + // Verwende "from" Feld wenn vorhanden, sonst cached Werte + const pid = from && from.e ? from.e : cachedPid; + const hostId = from && from.h ? from.h : cachedHostId; + + // Process PID + if (pid) { + attributes.push({ + key: 'process.pid', + value: { intValue: parseInt(pid, 10) } + }); + } + + // Host Name + if (hostId) { + attributes.push({ + key: 'host.name', + value: { stringValue: hostId } + }); + } + + return attributes; +} + +/** + * Bestimmt den Status Code basierend auf Error Count + * @param {number} errorCount - Instana error count + * @returns {Object} OTEL status object + */ +function createStatus(errorCount) { + // OTEL Status Code: 0=UNSET, 1=OK, 2=ERROR + if (errorCount > 0) { + return { code: 2 }; // ERROR + } + return { code: 1 }; // OK +} + +/** + * Transformiert einen einzelnen Instana Span zu OTEL Span + * @param {Object} instanaSpan - Instana span object + * @returns {Object} OTEL span object + */ +function transformSpan(instanaSpan) { + const otelSpan = { + traceId: instanaSpan.t, + spanId: instanaSpan.s, + name: instanaSpan.n || 'unknown', + kind: convertSpanKind(instanaSpan.k), + startTimeUnixNano: msToNano(instanaSpan.ts), + endTimeUnixNano: msToNano(instanaSpan.ts + instanaSpan.d), + attributes: createAttributes(instanaSpan.data), + status: createStatus(instanaSpan.ec || 0) + }; + + // Parent Span ID ist optional + if (instanaSpan.p) { + otelSpan.parentSpanId = instanaSpan.p; + } + + return otelSpan; +} + +/** + * Transformiert Instana Traces zu OTEL Format + * @param {Array} instanaTraces - Array von Instana spans + * @returns {Object} OTEL traces object + */ +function transform(instanaTraces) { + if (!Array.isArray(instanaTraces) || instanaTraces.length === 0) { + return { + resourceSpans: [] + }; + } + + // Gruppiere Spans nach Resource (from field) + const spansByResource = new Map(); + + instanaTraces.forEach(function (instanaSpan) { + // Cache PID und Host-ID aus dem ersten Span für Metrics + if (instanaSpan.f) { + if (instanaSpan.f.e && !cachedPid) { + setPid(instanaSpan.f.e); + } + if (instanaSpan.f.h && !cachedHostId) { + setHostId(instanaSpan.f.h); + } + } + + const resourceKey = JSON.stringify(instanaSpan.f || {}); + + if (!spansByResource.has(resourceKey)) { + spansByResource.set(resourceKey, { + resource: instanaSpan.f, + spans: [] + }); + } + + spansByResource.get(resourceKey).spans.push(instanaSpan); + }); + + // Erstelle OTEL ResourceSpans + const resourceSpans = Array.from(spansByResource.values()).map(function (group) { + const otelSpans = group.spans.map(transformSpan); + + return { + resource: { + attributes: createResourceAttributes(group.resource) + }, + scopeSpans: [ + { + scope: { + name: '@instana/collector', + version: '1.0.0' + }, + spans: otelSpans + } + ] + }; + }); + + return { + resourceSpans: resourceSpans + }; +} + +/** + * Flacht verschachtelte Objekte zu einem flachen Objekt mit Punkt-Notation + * @param {Object} obj - Verschachteltes Objekt + * @param {string} prefix - Prefix für die Keys + * @returns {Object} Flaches Objekt + */ +function flattenObject(obj, prefix) { + prefix = prefix || ''; + const flattened = {}; + + for (const key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + + const value = obj[key]; + const newKey = prefix ? `${prefix}.${key}` : key; + + if (value === null || value === undefined) { + continue; + } + + if (typeof value === 'object' && !Array.isArray(value)) { + // Rekursiv verschachtelte Objekte flach machen + const nested = flattenObject(value, newKey); + for (const nestedKey in nested) { + if (nested.hasOwnProperty(nestedKey)) { + flattened[nestedKey] = nested[nestedKey]; + } + } + } else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + // Nur primitive Werte übernehmen + flattened[newKey] = value; + } + } + + return flattened; +} + +/** + * Transformiert Instana Metrics zu OTEL Format + * @param {Array} instanaMetrics - Array von Instana metrics + * @returns {Object} OTEL metrics object + */ +function transformMetrics(instanaMetrics) { + // Wenn es ein Objekt ist, konvertiere die Werte zu einem Array + let metricsArray = instanaMetrics; + + if (!Array.isArray(instanaMetrics)) { + if (!instanaMetrics || typeof instanaMetrics !== 'object') { + return { + resourceMetrics: [] + }; + } + + // Flache das verschachtelte Objekt + const flattenedMetrics = flattenObject(instanaMetrics); + + // Konvertiere flaches Objekt zu Array von Metrics + metricsArray = Object.keys(flattenedMetrics).map(function (key) { + const value = flattenedMetrics[key]; + return { + name: key, + value: value, + timestamp: Date.now(), + unit: '', + from: instanaMetrics.from + }; + }); + } + + if (metricsArray.length === 0) { + return { + resourceMetrics: [] + }; + } + + // Gruppiere Metrics nach Resource + const metricsByResource = new Map(); + + metricsArray.forEach(function (instanaMetric) { + const resourceKey = JSON.stringify(instanaMetric.from || {}); + + if (!metricsByResource.has(resourceKey)) { + metricsByResource.set(resourceKey, { + resource: instanaMetric.from, + metrics: [] + }); + } + + metricsByResource.get(resourceKey).metrics.push(instanaMetric); + }); + + // Erstelle OTEL ResourceMetrics + const resourceMetrics = Array.from(metricsByResource.values()).map(function (group) { + const otelMetrics = group.metrics.map(function (metric) { + // Bestimme den Metrik-Typ basierend auf dem Wert + let metricData; + if (typeof metric.value === 'number') { + metricData = { + sum: { + dataPoints: [ + { + asDouble: metric.value + } + ] + } + }; + } else if (typeof metric.value === 'string') { + // Strings als Gauge mit String-Wert (nicht standard OTLP, aber für Debugging) + metricData = { + gauge: { + dataPoints: [ + { + asDouble: 0, + attributes: [ + { + key: 'value', + value: { stringValue: metric.value } + } + ] + } + ] + } + }; + } else if (typeof metric.value === 'boolean') { + metricData = { + gauge: { + dataPoints: [ + { + asDouble: metric.value ? 1 : 0 + } + ] + } + }; + } else { + // Fallback für unbekannte Typen + metricData = { + sum: { + dataPoints: [ + { + asDouble: 0 + } + ] + } + }; + } + + return { + name: metric.name || 'unknown.metric', + ...metricData + }; + }); + + return { + resource: { + attributes: createResourceAttributes(group.resource) + }, + scopeMetrics: [ + { + scope: { + name: 'instrumentationScope', + version: '13.2' + }, + metrics: otelMetrics + } + ] + }; + }); + + return { + resourceMetrics: resourceMetrics + }; +} + +module.exports = transform; +module.exports.transformTraces = transform; +module.exports.transformMetrics = transformMetrics; +module.exports.setHostId = setHostId; +module.exports.setPid = setPid; + +// Made with Bob