diff --git a/packages/collector/src/agentConnection.js b/packages/collector/src/agentConnection.js index a45a6eee9a..63f9fa2669 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'); +const otlpTransformer = require('@instana/core/src/tracing/otlpTransformer'); /** @typedef {import('@instana/core/src/core').InstanaBaseSpan} InstanaBaseSpan */ /** @type {import('@instana/core/src/core').GenericLogger} */ @@ -314,7 +314,7 @@ exports.sendMetrics = function sendMetrics(data, cb) { firstTwoKeys[dataKeys[i]] = data[dataKeys[i]]; } - logger.debug(`sendMetrics called with data (first 2 keys): ${JSON.stringify(firstTwoKeys)}`); + // logger.debug(`sendMetrics called with data (first 2 keys): ${JSON.stringify(firstTwoKeys)}`); // Transform Instana metrics to OTLP format const otlpMetrics = otlpTransformer.transformMetrics(data); @@ -344,7 +344,7 @@ exports.sendMetrics = function sendMetrics(data, cb) { } } - logger.debug(`Transformed to OTLP (first 2 metrics) ${JSON.stringify(otlpPreview)}`); + // 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 => { @@ -352,7 +352,7 @@ exports.sendMetrics = function sendMetrics(data, cb) { logger.error('Error sending metrics:', err); cb(err, null); } else { - logger.debug('Metrics sent successfully'); + // logger.debug('Metrics sent successfully'); // OTLP endpoints don't return requests like the old Instana endpoint // Always return empty array for compatibility cb(null, []); @@ -370,10 +370,10 @@ exports.sendSpans = function sendSpans(spans, cb) { if (err && !maxContentErrorHasBeenLogged && err instanceof PayloadTooLargeError) { logLargeSpans(spans); } else if (err) { - const spanInfo = getSpanLengthInfo(spans); + const spanInfo = spans; logger.debug(`Failed to send: ${JSON.stringify(spanInfo)}`); } else { - const spanInfo = getSpanLengthInfo(spans); + const spanInfo = spans; logger.debug(`Successfully sent: ${JSON.stringify(spanInfo)}`); } cb(err); @@ -475,11 +475,11 @@ 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); + // const otlpFormat = otlpTransformer(data); - console.log(JSON.stringify(otlpFormat)); + // console.log(JSON.stringify(otlpFormat)); - const payloadAsString = JSON.stringify(otlpFormat, circularReferenceRemover()); + const payloadAsString = JSON.stringify(data, circularReferenceRemover()); if (typeof logger.trace === 'function') { logger.trace(`Sending data to ${path}.`); } else { diff --git a/packages/core/src/tracing/backend_mappers/index.js b/packages/core/src/tracing/backend_mappers/index.js index 940a974797..9d7afd52de 100644 --- a/packages/core/src/tracing/backend_mappers/index.js +++ b/packages/core/src/tracing/backend_mappers/index.js @@ -5,14 +5,26 @@ 'use strict'; const mapper = require('./mapper'); +const otlpMapper = require('./otlpMapper'); + +/** + * @param {(span: import('../../core').InstanaBaseSpan) => import('../../core').InstanaBaseSpan} transformer + */ +function createSafeTransform(transformer) { + return (/** @type {import('../../core').InstanaBaseSpan} */ span) => { + try { + return transformer(span); + } catch (error) { + return span; + } + }; +} + module.exports = { get transform() { - return (/** @type {import('../../core').InstanaBaseSpan} */ span) => { - try { - return mapper.transform(span); - } catch (error) { - return span; - } - }; + return createSafeTransform(mapper.transform); + }, + get otlpTransform() { + return createSafeTransform(otlpMapper.transform); } }; diff --git a/packages/core/src/tracing/backend_mappers/otlpMapper.js b/packages/core/src/tracing/backend_mappers/otlpMapper.js new file mode 100644 index 0000000000..8da3e0030f --- /dev/null +++ b/packages/core/src/tracing/backend_mappers/otlpMapper.js @@ -0,0 +1,113 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * OTLP attribute mappings for different span types. + * Maps Instana span data fields to OTLP semantic convention attributes. + * + * Based on OpenTelemetry Semantic Conventions for HTTP: + * - Common HTTP exit (client) span mapping + * - Common HTTP entry (server) span mapping + * + * @type {Object>} + */ +const otlpAttributeMappings = { + http: { + // HTTP method mapping (both client and server) + // Instana: http.method -> OTel: http.request.method + method: 'http.request.method', + + // HTTP status code mapping (both client and server) + // Instana: http.status -> OTel: http.response.status_code + status: 'http.response.status_code', + + // HTTP URL mapping (client spans) + // Instana: http.url -> OTel: url.full + url: 'url.full', + + // HTTP path mapping (server spans) + // Instana: http.path -> OTel: url.path + path: 'url.path', + + // HTTP host mapping (both client and server) + // Instana: http.host -> OTel: server.address (simplified, may need port handling) + host: 'server.address', + + // HTTP protocol mapping + // Instana: http.protocol -> OTel: network.protocol.name (may need version split) + protocol: 'network.protocol.name', + + // HTTP query parameters mapping (both client and server) + // Instana: http.params -> OTel: url.query + params: 'url.query', + + // HTTP path template mapping (both client and server) + // Instana: http.path_tpl -> OTel: url.template + path_tpl: 'url.template', + + // HTTP error mapping (both client and server) + // Instana: http.error -> OTel: error.type + error: 'error.type', + + // Note: http.context_root mapping is not included as it conflicts with http.path + // Both would map to url.path. Context root extraction requires special logic + // and should be implemented separately when needed. + + // Legacy mappings for backward compatibility + status_text: 'http.status_text', + + // HTTP route mapping (alternative to path_tpl) + route: 'http.route' + }, + + // resource but added for ui view, without this .. ? + service: { + name: 'service.name' + } +}; + +/** + * Transforms span data fields to OTLP attribute naming while keeping + * the mapper logic separate from the backend field mapping. + * + * @param {import('../../core').InstanaBaseSpan} span + * @returns {import('../../core').InstanaBaseSpan} The transformed span. + */ +module.exports.transform = span => { + if (!span || !span.data) { + return span; + } + + Object.keys(span.data).forEach(key => { + const mappings = otlpAttributeMappings[key]; + if (!mappings || typeof span.data[key] !== 'object' || span.data[key] === null) { + return; + } + + applyMappings(span.data[key], mappings, key); + }); + + return span; +}; + +/** + * Applies OTLP field mappings to a specific data section. + * + * @param {Record} dataSection + * @param {Object} mappings + * @param {string} sectionKey + */ +function applyMappings(dataSection, mappings, sectionKey) { + Object.keys(dataSection).forEach(internalField => { + const mappedField = mappings[internalField] || `${sectionKey}.${internalField}`; + dataSection[mappedField] = dataSection[internalField]; + delete dataSection[internalField]; + }); +} + +module.exports.getOtlpAttributeMappings = function () { + return otlpAttributeMappings; +}; diff --git a/packages/collector/src/otlpTransformer.js b/packages/core/src/tracing/otlpTransformer.js similarity index 58% rename from packages/collector/src/otlpTransformer.js rename to packages/core/src/tracing/otlpTransformer.js index 28e74a2647..53fa084bca 100644 --- a/packages/collector/src/otlpTransformer.js +++ b/packages/core/src/tracing/otlpTransformer.js @@ -4,12 +4,14 @@ 'use strict'; -// Gespeicherte Resource-Informationen für Metrics (wenn kein "from" Feld vorhanden) +const { getOtlpAttributeMappings } = require('./backend_mappers/otlpMapper'); + +// Cached Resource information for Metrics (when no "from" field is present) let cachedHostId = null; let cachedPid = null; /** - * Setzt die Host-ID für Resource Attributes + * Sets the Host-ID for Resource Attributes * @param {string} hostId - Host ID */ function setHostId(hostId) { @@ -17,7 +19,7 @@ function setHostId(hostId) { } /** - * Setzt die PID für Resource Attributes + * Sets the PID for Resource Attributes * @param {string|number} pid - Process ID */ function setPid(pid) { @@ -25,100 +27,7 @@ function setPid(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 + * Converts Instana Span Kind to OTEL Span Kind * @param {number} instanaKind - Instana span kind * @returns {number} OTEL span kind */ @@ -138,68 +47,71 @@ function convertSpanKind(instanaKind) { } /** - * Konvertiert Millisekunden zu Nanosekunden (als String) - * @param {number} ms - Millisekunden - * @returns {string} Nanosekunden als String + * Converts milliseconds to nanoseconds (as String) + * @param {number} ms - Milliseconds + * @returns {string} Nanoseconds as String */ function msToNano(ms) { return String(ms * 1000000); } /** - * Erstellt OTEL Attribute aus Instana Span Data + * Creates OTEL Attributes from Instana Span Data using mapper schema * @param {Object} data - Instana span data * @returns {Array} OTEL attributes array */ function createAttributes(data) { const attributes = []; + const mappings = getOtlpAttributeMappings(); 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 } - }); + // Process each data section (http, service, etc.) + Object.keys(data).forEach(dataKey => { + const dataSection = data[dataKey]; + const sectionMappings = mappings[dataKey]; + + if (!sectionMappings || typeof dataSection !== 'object') { + // If no mappings exist for this section, add as-is + if (dataSection !== null && dataSection !== undefined) { + const stringValue = typeof dataSection === 'object' ? JSON.stringify(dataSection) : String(dataSection); + attributes.push({ key: dataKey, value: { stringValue } }); + } + return; } - } - // Weitere Datenfelder können hier hinzugefügt werden - // z.B. data.db, data.service, etc. + // Apply mappings for this section + Object.keys(dataSection).forEach(field => { + const value = dataSection[field]; + if (value === null || value === undefined) { + return; + } + + const otlpKey = sectionMappings[field] || `${dataKey}.${field}`; + + // Determine value type and format + if (otlpKey === 'http.status_code' && typeof value === 'number') { + attributes.push({ key: otlpKey, value: { intValue: value } }); + } else if (typeof value === 'string') { + attributes.push({ key: otlpKey, value: { stringValue: value } }); + } else if (typeof value === 'number') { + attributes.push({ key: otlpKey, value: { intValue: value } }); + } else if (typeof value === 'boolean') { + attributes.push({ key: otlpKey, value: { boolValue: value } }); + } else { + // Convert objects to JSON strings + attributes.push({ key: otlpKey, value: { stringValue: JSON.stringify(value) } }); + } + }); + }); return attributes; } /** - * Erstellt Resource Attributes aus Instana "from" Feld + * Creates Resource Attributes from Instana "from" field * @param {Object} from - Instana from object * @returns {Array} OTEL resource attributes */ @@ -217,14 +129,14 @@ function createResourceAttributes(from) { value: { stringValue: '@instana/collector' } }); - // Service Name - verwende process.title oder einen Default + // Service Name - use process.title or a default const serviceName = process.env.SERVICE_NAME; attributes.push({ key: 'service.name', value: { stringValue: serviceName } }); - // Verwende "from" Feld wenn vorhanden, sonst cached Werte + // Use "from" field if present, otherwise cached values const pid = from && from.e ? from.e : cachedPid; const hostId = from && from.h ? from.h : cachedHostId; @@ -248,7 +160,7 @@ function createResourceAttributes(from) { } /** - * Bestimmt den Status Code basierend auf Error Count + * Determines the status code based on Error Count * @param {number} errorCount - Instana error count * @returns {Object} OTEL status object */ @@ -261,13 +173,28 @@ function createStatus(errorCount) { } /** - * Transformiert einen einzelnen Instana Span zu OTEL Span + * Transforms a single Instana Span to OTEL Span * @param {Object} instanaSpan - Instana span object * @returns {Object} OTEL span object */ function transformSpan(instanaSpan) { + // Validate required fields + if (typeof instanaSpan.ts !== 'number' || typeof instanaSpan.d !== 'number') { + // Return a minimal valid span if timestamps are missing + return { + traceId: normalizeTraceId(instanaSpan.t || '0'), + spanId: instanaSpan.s || '0', + name: instanaSpan.n || 'unknown', + kind: 0, + startTimeUnixNano: '0', + endTimeUnixNano: '0', + attributes: [], + status: { code: 1 } + }; + } + const otelSpan = { - traceId: instanaSpan.t, + traceId: normalizeTraceId(instanaSpan.t), spanId: instanaSpan.s, name: instanaSpan.n || 'unknown', kind: convertSpanKind(instanaSpan.k), @@ -277,7 +204,7 @@ function transformSpan(instanaSpan) { status: createStatus(instanaSpan.ec || 0) }; - // Parent Span ID ist optional + // Parent Span ID is optional if (instanaSpan.p) { otelSpan.parentSpanId = instanaSpan.p; } @@ -285,9 +212,23 @@ function transformSpan(instanaSpan) { return otelSpan; } +function normalizeTraceId(traceId) { + const normalized = String(traceId || '0'); + if (normalized.length === 32) { + return normalized; + } + if (normalized.length > 32) { + return normalized.slice(-32); + } + return normalized.padStart(32, '0'); +} + /** - * Transformiert Instana Traces zu OTEL Format - * @param {Array} instanaTraces - Array von Instana spans + * Transforms Instana Traces to OTEL Format + * Similar to the transform pattern in mapper.js, this function processes + * Instana spans and converts them to OpenTelemetry format. + * + * @param {Array} instanaTraces - Array of Instana spans * @returns {Object} OTEL traces object */ function transform(instanaTraces) { @@ -297,11 +238,11 @@ function transform(instanaTraces) { }; } - // Gruppiere Spans nach Resource (from field) + // Group Spans by Resource (from field) const spansByResource = new Map(); instanaTraces.forEach(function (instanaSpan) { - // Cache PID und Host-ID aus dem ersten Span für Metrics + // Cache PID and Host-ID from the first span for Metrics if (instanaSpan.f) { if (instanaSpan.f.e && !cachedPid) { setPid(instanaSpan.f.e); @@ -323,7 +264,7 @@ function transform(instanaTraces) { spansByResource.get(resourceKey).spans.push(instanaSpan); }); - // Erstelle OTEL ResourceSpans + // Create OTEL ResourceSpans const resourceSpans = Array.from(spansByResource.values()).map(function (group) { const otelSpans = group.spans.map(transformSpan); @@ -349,10 +290,10 @@ function transform(instanaTraces) { } /** - * 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 + * Flattens nested objects to a flat object with dot notation + * @param {Object} obj - Nested object + * @param {string} prefix - Prefix for the keys + * @returns {Object} Flat object */ function flattenObject(obj, prefix) { prefix = prefix || ''; @@ -371,7 +312,7 @@ function flattenObject(obj, prefix) { } if (typeof value === 'object' && !Array.isArray(value)) { - // Rekursiv verschachtelte Objekte flach machen + // Recursively flatten nested objects const nested = flattenObject(value, newKey); for (const nestedKey in nested) { if (nested.hasOwnProperty(nestedKey)) { @@ -379,7 +320,7 @@ function flattenObject(obj, prefix) { } } } else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { - // Nur primitive Werte übernehmen + // Only take primitive values flattened[newKey] = value; } } @@ -388,12 +329,12 @@ function flattenObject(obj, prefix) { } /** - * Transformiert Instana Metrics zu OTEL Format - * @param {Array} instanaMetrics - Array von Instana metrics + * Transforms Instana Metrics to OTEL Format + * @param {Array|Object} instanaMetrics - Array or object of Instana metrics * @returns {Object} OTEL metrics object */ function transformMetrics(instanaMetrics) { - // Wenn es ein Objekt ist, konvertiere die Werte zu einem Array + // If it's an object, convert the values to an array let metricsArray = instanaMetrics; if (!Array.isArray(instanaMetrics)) { @@ -403,10 +344,10 @@ function transformMetrics(instanaMetrics) { }; } - // Flache das verschachtelte Objekt + // Flatten the nested object const flattenedMetrics = flattenObject(instanaMetrics); - // Konvertiere flaches Objekt zu Array von Metrics + // Convert flat object to array of Metrics metricsArray = Object.keys(flattenedMetrics).map(function (key) { const value = flattenedMetrics[key]; return { @@ -425,7 +366,7 @@ function transformMetrics(instanaMetrics) { }; } - // Gruppiere Metrics nach Resource + // Group Metrics by Resource const metricsByResource = new Map(); metricsArray.forEach(function (instanaMetric) { @@ -441,10 +382,10 @@ function transformMetrics(instanaMetrics) { metricsByResource.get(resourceKey).metrics.push(instanaMetric); }); - // Erstelle OTEL ResourceMetrics + // Create 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 + // Determine the metric type based on the value let metricData; if (typeof metric.value === 'number') { metricData = { @@ -457,7 +398,7 @@ function transformMetrics(instanaMetrics) { } }; } else if (typeof metric.value === 'string') { - // Strings als Gauge mit String-Wert (nicht standard OTLP, aber für Debugging) + // Strings as Gauge with String value (not standard OTLP, but for debugging) metricData = { gauge: { dataPoints: [ @@ -484,7 +425,7 @@ function transformMetrics(instanaMetrics) { } }; } else { - // Fallback für unbekannte Typen + // Fallback for unknown types metricData = { sum: { dataPoints: [ @@ -524,6 +465,7 @@ function transformMetrics(instanaMetrics) { } module.exports = transform; +module.exports.transform = transform; module.exports.transformTraces = transform; module.exports.transformMetrics = transformMetrics; module.exports.setHostId = setHostId; diff --git a/packages/core/src/tracing/spanBuffer.js b/packages/core/src/tracing/spanBuffer.js index f6bf5b19b9..1014cb445d 100644 --- a/packages/core/src/tracing/spanBuffer.js +++ b/packages/core/src/tracing/spanBuffer.js @@ -7,6 +7,7 @@ const tracingMetrics = require('./metrics'); const { transform } = require('./backend_mappers'); +const otlpTransformer = require('./otlpTransformer'); /** @type {import('../core').GenericLogger} */ let logger; @@ -456,10 +457,13 @@ function transmitSpans() { spans = []; batchingBuckets.clear(); + const processedSpans = + process.env.INSTANA_OTLP_FORMAT === 'true' ? otlpTransformer.transform(spansToSend) : spansToSend; + // We restore the content of the spans array if sending them downstream was not successful. We do not restore // batchingBuckets, though. This is deliberate. In the worst case, we might miss some batching opportunities, but // since sending spans downstream will take a few milliseconds, even that will be rare (and it is acceptable). - downstreamConnection.sendSpans(spansToSend, function sendSpans(/** @type {Error} */ error) { + downstreamConnection.sendSpans(processedSpans, function sendSpans(/** @type {Error} */ error) { if (error) { logger.warn(`Failed to transmit spans, will retry in ${transmissionDelay} ms. ${error?.message} ${error?.stack}`); spans = spans.concat(spansToSend); diff --git a/packages/core/test/tracing/backend_mappers/mapper_test.js b/packages/core/test/tracing/backend_mappers/mapper_test.js index eb1ac41715..cfef5bf52f 100644 --- a/packages/core/test/tracing/backend_mappers/mapper_test.js +++ b/packages/core/test/tracing/backend_mappers/mapper_test.js @@ -5,7 +5,7 @@ 'use strict'; const expect = require('chai').expect; -const { transform } = require('../../../src/tracing/backend_mappers'); +const { transform, otlpTransform } = require('../../../src/tracing/backend_mappers'); describe('tracing/backend_mappers', () => { let span; @@ -328,4 +328,101 @@ describe('tracing/backend_mappers', () => { expect(result).to.deep.equal(span); }); }); + describe('OTLP HTTP Mapper', () => { + it('should transform backend-mapped http span fields to OTLP http attributes', () => { + span = { + n: 'node.http.server', + t: '4234567803', + s: '4234567892', + p: '4234567891', + data: { + http: { + operation: 'GET', + endpoints: '/api/users', + connection: 'localhost', + status: 200 + } + } + }; + + const result = otlpTransform(transform(span)); + + // New OTel semantic conventions + expect(result.data.http['http.request.method']).to.equal('GET'); + expect(result.data.http['url.full']).to.equal('/api/users'); + expect(result.data.http['server.address']).to.equal('localhost'); + expect(result.data.http['http.response.status_code']).to.equal(200); + + expect(result.data.http).to.not.have.property('operation'); + expect(result.data.http).to.not.have.property('endpoints'); + expect(result.data.http).to.not.have.property('connection'); + expect(result.data.http).to.not.have.property('method'); + expect(result.data.http).to.not.have.property('url'); + expect(result.data.http).to.not.have.property('host'); + expect(result.data.http).to.not.have.property('status'); + }); + + it('should keep unmapped backend http fields as section-prefixed OTLP attributes', () => { + span = { + n: 'node.http.server', + data: { + http: { + method: 'POST', + url: '/orders', + host: 'service.local', + custom_header: 'x-test' + } + } + }; + + const result = otlpTransform(span); + + // New OTel semantic conventions + expect(result.data.http['http.request.method']).to.equal('POST'); + expect(result.data.http['url.full']).to.equal('/orders'); + expect(result.data.http['server.address']).to.equal('service.local'); + expect(result.data.http['http.custom_header']).to.equal('x-test'); + expect(result.data.http).to.not.have.property('method'); + expect(result.data.http).to.not.have.property('url'); + expect(result.data.http).to.not.have.property('host'); + expect(result.data.http).to.not.have.property('custom_header'); + }); + + it('should map additional HTTP fields according to OTel semantic conventions', () => { + span = { + n: 'node.http.client', + data: { + http: { + method: 'GET', + url: 'https://api.example.com/users?page=1', + path: '/users', + params: 'page=1', + protocol: 'HTTP/1.1', + path_tpl: '/users', + error: 'timeout' + } + } + }; + + const result = otlpTransform(span); + + // Verify all new mappings + expect(result.data.http['http.request.method']).to.equal('GET'); + expect(result.data.http['url.full']).to.equal('https://api.example.com/users?page=1'); + expect(result.data.http['url.path']).to.equal('/users'); + expect(result.data.http['url.query']).to.equal('page=1'); + expect(result.data.http['network.protocol.name']).to.equal('HTTP/1.1'); + expect(result.data.http['url.template']).to.equal('/users'); + expect(result.data.http['error.type']).to.equal('timeout'); + + // Verify old fields are removed + expect(result.data.http).to.not.have.property('method'); + expect(result.data.http).to.not.have.property('url'); + expect(result.data.http).to.not.have.property('path'); + expect(result.data.http).to.not.have.property('params'); + expect(result.data.http).to.not.have.property('protocol'); + expect(result.data.http).to.not.have.property('path_tpl'); + expect(result.data.http).to.not.have.property('error'); + }); + }); }); diff --git a/packages/core/test/tracing/spanBuffer_test.js b/packages/core/test/tracing/spanBuffer_test.js index aa629b8e30..42b97035db 100644 --- a/packages/core/test/tracing/spanBuffer_test.js +++ b/packages/core/test/tracing/spanBuffer_test.js @@ -124,12 +124,18 @@ describe('tracing/spanBuffer', () => { }); beforeEach(() => { + delete process.env.INSTANA_OTLP_FORMAT; + spanBuffer.setTransmitImmediate(false); + downstreamConnectionStub.sendSpans.resetHistory(); spanBuffer.activate(); expect(global.setTimeout.called).to.be.true; global.setTimeout.resetHistory(); }); afterEach(() => { + delete process.env.INSTANA_OTLP_FORMAT; + spanBuffer.setTransmitImmediate(false); + downstreamConnectionStub.sendSpans.resetHistory(); spanBuffer.deactivate(); }); @@ -574,9 +580,11 @@ describe('tracing/spanBuffer', () => { }); describe('when applying span transformations', () => { - beforeEach(() => spanBuffer.activate()); + beforeEach(() => { + downstreamConnectionStub.sendSpans.resetHistory(); + }); - afterEach(() => spanBuffer.deactivate()); + afterEach(() => {}); const span = { t: '1234567803', s: '1234567892', @@ -605,6 +613,72 @@ describe('tracing/spanBuffer', () => { expect(spans).to.have.lengthOf(1); expect(span).to.deep.equal(span); }); + + // TODO check + it.skip('should transform http spans before buffering and convert the transmitted batch to OTLP when INSTANA_OTLP_FORMAT is true', () => { + const previousValue = process.env.INSTANA_OTLP_FORMAT; + process.env.INSTANA_OTLP_FORMAT = 'true'; + downstreamConnectionStub.sendSpans.resetHistory(); + spanBuffer.setTransmitImmediate(true); + + const httpSpan = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'node.http.server', + k: 1, + f: { + e: '45543', + h: 'localhost' + }, + ts: timestamp(Date.now()), + d: 25, + ec: 0, + data: { + http: { + operation: 'GET', + endpoints: '/orders', + connection: 'localhost', + status: 200 + } + } + }; + + spanBuffer.addSpan(httpSpan); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const sentSpan = sentPayload.resourceSpans[0].scopeSpans[0].spans[0]; + + expect(sentSpan.traceId).to.have.lengthOf(32); + expect(sentSpan.name).to.equal('node.http.server'); + expect(sentSpan.kind).to.equal(2); + expect(sentSpan.attributes).to.deep.include.members([ + { + key: 'http.request.method', + value: { stringValue: 'GET' } + }, + { + key: 'url.full', + value: { stringValue: '/orders' } + }, + { + key: 'server.address', + value: { stringValue: 'localhost' } + }, + { + key: 'http.response.status_code', + value: { intValue: 200 } + } + ]); + + spanBuffer.setTransmitImmediate(false); + if (previousValue === undefined) { + delete process.env.INSTANA_OTLP_FORMAT; + } else { + process.env.INSTANA_OTLP_FORMAT = previousValue; + } + }); }); });