November 30, 2017
++ The Eclipse Foundation makes available all content in this plug-in + ("Content"). Unless otherwise indicated below, the Content + is provided to you under the terms and conditions of the Eclipse + Public License Version 2.0 ("EPL"). A copy of the EPL is + available at https://www.eclipse.org/legal/epl-2.0. + For purposes of the EPL, "Program" will mean the Content. +
+ ++ If you did not receive this Content directly from the Eclipse + Foundation, the Content is being redistributed by another party + ("Redistributor") and different terms and conditions may + apply to your use of any object code in the Content. Check the + Redistributor's license that was provided with the Content. If no such + license exists, contact the Redistributor. Unless otherwise indicated + below, the terms and conditions of the EPL still apply to any source + code in the Content and such source code may be obtained at https://www.eclipse.org. +
+ + + diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/build.properties b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/build.properties new file mode 100644 index 000000000..fc25d48e3 --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/build.properties @@ -0,0 +1,18 @@ +############################################################################### +# Copyright (c) 2026 Ericsson +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License 2.0 +# which accompanies this distribution, and is available at +# https://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +############################################################################### + +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + about.html,\ + plugin.properties,\ + traces/ diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/plugin.properties b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/plugin.properties new file mode 100644 index 000000000..d6b95e315 --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/plugin.properties @@ -0,0 +1,13 @@ +############################################################################### +# Copyright (c) 2026 Ericsson +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License 2.0 +# which accompanies this distribution, and is available at +# https://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +############################################################################### + +Bundle-Vendor = Eclipse Trace Compass Incubator +Bundle-Name = Trace Compass Incubator OTLP Core Tests Plug-in diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/src/org/eclipse/tracecompass/incubator/otlp/core/tests/OtlpSpanLifeTest.java b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/src/org/eclipse/tracecompass/incubator/otlp/core/tests/OtlpSpanLifeTest.java new file mode 100644 index 000000000..ae38f312c --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core.tests/src/org/eclipse/tracecompass/incubator/otlp/core/tests/OtlpSpanLifeTest.java @@ -0,0 +1,177 @@ +/******************************************************************************* + * Copyright (c) 2026 Ericsson + * + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 which + * accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.tracecompass.incubator.otlp.core.tests; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.tracecompass.incubator.internal.opentracing.core.analysis.spanlife.SpanLifeAnalysis; +import org.eclipse.tracecompass.incubator.internal.opentracing.core.analysis.spanlife.SpanLifeStateProvider; +import org.eclipse.tracecompass.incubator.internal.otlp.core.trace.OtlpTrace; +import org.eclipse.tracecompass.statesystem.core.ITmfStateSystem; +import org.eclipse.tracecompass.statesystem.core.StateSystemUtils; +import org.eclipse.tracecompass.statesystem.core.exceptions.AttributeNotFoundException; +import org.eclipse.tracecompass.statesystem.core.exceptions.StateSystemDisposedException; +import org.eclipse.tracecompass.statesystem.core.interval.ITmfStateInterval; +import org.eclipse.tracecompass.tmf.core.event.ITmfEvent; +import org.eclipse.tracecompass.tmf.core.exceptions.TmfTraceException; +import org.eclipse.tracecompass.tmf.core.signal.TmfTraceOpenedSignal; +import org.eclipse.tracecompass.tmf.core.trace.TmfTraceManager; +import org.eclipse.tracecompass.tmf.core.trace.TmfTraceUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Test that the SpanLifeAnalysis correctly records span durations for an OTLP + * trace. + * + * @author Matthew Khouzam + */ +public class OtlpSpanLifeTest { + + private static final String TRACE_PATH = "traces/hotrod-jaeger.json"; //$NON-NLS-1$ + + private OtlpTrace fTrace; + + private static void deleteStateFiles(@NonNull OtlpTrace trace) { + File suppDir = new File(TmfTraceManager.getSupplementaryFileDir(trace)); + File[] files = suppDir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.getName().endsWith(".ht")) { //$NON-NLS-1$ + file.delete(); + } + } + } + } + + /** + * Setup: open the trace and fire the traceOpened signal so analysis modules + * are registered. + * + * @throws TmfTraceException + * if the trace cannot be opened + */ + @Before + public void setUp() throws TmfTraceException { + OtlpTrace trace = new OtlpTrace(); + trace.initTrace(null, TRACE_PATH, ITmfEvent.class); + deleteStateFiles(trace); + trace.traceOpened(new TmfTraceOpenedSignal(this, trace, null)); + fTrace = trace; + } + + /** + * Dispose the trace after each test. + */ + @After + public void tearDown() { + OtlpTrace trace = fTrace; + if (trace != null) { + trace.dispose(); + } + } + + /** + * Load the OTLP trace, run SpanLifeAnalysis, and verify that each span in + * the state system has a non-zero duration (endTime > startTime for the + * interval where the span is active). + * + * @throws AttributeNotFoundException + * if a state system attribute is missing + * @throws StateSystemDisposedException + * if the state system is disposed + */ + @Test + public void testSpanDurations() throws AttributeNotFoundException, StateSystemDisposedException { + OtlpTrace trace = fTrace; + assertNotNull("Trace should not be null", trace); //$NON-NLS-1$ + + // Find the SpanLifeAnalysis module registered for this trace + SpanLifeAnalysis analysis = null; + for (SpanLifeAnalysis module : TmfTraceUtils.getAnalysisModulesOfClass(trace, SpanLifeAnalysis.class)) { + analysis = module; + break; + } + assertNotNull("SpanLifeAnalysis should be available for OtlpTrace", analysis); //$NON-NLS-1$ + + // Schedule the analysis and wait for completion + analysis.schedule(); + assertTrue("SpanLifeAnalysis should complete successfully", analysis.waitForCompletion()); //$NON-NLS-1$ + + // Get the state system built by the analysis + ITmfStateSystem ss = analysis.getStateSystem(); + assertNotNull("State system should not be null after analysis completes", ss); //$NON-NLS-1$ + + // Get top-level quarks (trace IDs) + List<@NonNull Integer> traceIdQuarks = ss.getSubAttributes(ITmfStateSystem.ROOT_ATTRIBUTE, false); + assertFalse("There should be at least one traceId in the state system", traceIdQuarks.isEmpty()); //$NON-NLS-1$ + + // Use the first traceId + int traceIdQuark = traceIdQuarks.get(0); + String traceIdName = ss.getAttributeName(traceIdQuark); + + // Navigate to the openTracingSpans sub-attribute + int openTracingQuark = ss.getQuarkRelative(traceIdQuark, SpanLifeStateProvider.OPEN_TRACING_ATTRIBUTE); + + // Collect all leaf span quarks recursively under openTracingSpans + List<@NonNull Integer> spanQuarks = ss.getSubAttributes(openTracingQuark, true); + assertFalse("There should be span quarks under openTracingSpans for traceId " + traceIdName, //$NON-NLS-1$ + spanQuarks.isEmpty()); + + int spanCount = 0; + long ssStart = ss.getStartTime(); + long ssEnd = ss.getCurrentEndTime(); + + for (int quark : spanQuarks) { + // Only check leaf quarks (actual span entries have no children) + if (!ss.getSubAttributes(quark, false).isEmpty()) { + continue; + } + + // Query the full history of this quark to find the non-null interval + ListNovember 30, 2017
++ The Eclipse Foundation makes available all content in this plug-in + ("Content"). Unless otherwise indicated below, the Content + is provided to you under the terms and conditions of the Eclipse + Public License Version 2.0 ("EPL"). A copy of the EPL is + available at https://www.eclipse.org/legal/epl-2.0. + For purposes of the EPL, "Program" will mean the Content. +
+ ++ If you did not receive this Content directly from the Eclipse + Foundation, the Content is being redistributed by another party + ("Redistributor") and different terms and conditions may + apply to your use of any object code in the Content. Check the + Redistributor's license that was provided with the Content. If no such + license exists, contact the Redistributor. Unless otherwise indicated + below, the terms and conditions of the EPL still apply to any source + code in the Content and such source code may be obtained at https://www.eclipse.org. +
+ + + \ No newline at end of file diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/build.properties b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/build.properties new file mode 100644 index 000000000..e9838e809 --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/build.properties @@ -0,0 +1,18 @@ +############################################################################### +# Copyright (c) 2026 Ericsson +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License 2.0 +# which accompanies this distribution, and is available at +# https://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +############################################################################### + +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + about.html,\ + plugin.properties,\ + .,\ + plugin.xml diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.properties b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.properties new file mode 100644 index 000000000..549353ecb --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.properties @@ -0,0 +1,13 @@ +############################################################################### +# Copyright (c) 2026 Ericsson +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License 2.0 +# which accompanies this distribution, and is available at +# https://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +############################################################################### + +Bundle-Vendor = Eclipse Trace Compass Incubator +Bundle-Name = Trace Compass OTLP Core Plug-in (Incubator) diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.xml b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.xml new file mode 100644 index 000000000..230059e8c --- /dev/null +++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/plugin.xml @@ -0,0 +1,32 @@ + + +
+ * {
+ * "traceId": "...",
+ * "spanId": "...",
+ * "parentSpanId": "...",
+ * "name": "...",
+ * "kind": 2,
+ * "startTimeUnixNano": "...",
+ * "endTimeUnixNano": "...",
+ * "attributes": [{"key": "...", "value": {"stringValue": "..."}}],
+ * "status": {"code": 1},
+ * "serviceName": "..." (injected by sorting job)
+ * }
+ *
+ *
+ * @author Matthew Khouzam
+ */
+public class OtlpField {
+
+ private static final Gson G_SON = new Gson();
+
+ /**
+ * Holds the common parsed header fields shared by parseJson and
+ * parseOtlpSpan.
+ */
+ private static class SpanHeader {
+ final String name;
+ final String traceId;
+ final String spanId;
+ final long startTimeNanos;
+ final long endTimeNanos;
+ final long durationNanos;
+ final @Nullable String parentSpanId;
+ final String serviceName;
+
+ SpanHeader(String name, String traceId, String spanId,
+ long startTimeNanos, long endTimeNanos, long durationNanos,
+ @Nullable String parentSpanId, String serviceName) {
+ this.name = name;
+ this.traceId = traceId;
+ this.spanId = spanId;
+ this.startTimeNanos = startTimeNanos;
+ this.endTimeNanos = endTimeNanos;
+ this.durationNanos = durationNanos;
+ this.parentSpanId = parentSpanId;
+ this.serviceName = serviceName;
+ }
+ }
+
+ private OtlpField() {
+ // utility class
+ }
+
+ /**
+ * Parse the common header fields from an OTLP span JSON object.
+ *
+ * @param root
+ * the parsed JSON object
+ * @return a SpanHeader, or null if any required field is missing or invalid
+ */
+ private static @Nullable SpanHeader parseHeader(JsonObject root) {
+ String name = optString(root, "name"); //$NON-NLS-1$
+ if (name == null) {
+ return null;
+ }
+ String traceId = optString(root, "traceId"); //$NON-NLS-1$
+ String spanId = optString(root, "spanId"); //$NON-NLS-1$
+ if (traceId == null || spanId == null) {
+ return null;
+ }
+
+ String startTimeStr = optString(root, "startTimeUnixNano"); //$NON-NLS-1$
+ String endTimeStr = optString(root, "endTimeUnixNano"); //$NON-NLS-1$
+ if (startTimeStr == null || endTimeStr == null) {
+ return null;
+ }
+
+ long startTimeNanos;
+ long endTimeNanos;
+ try {
+ startTimeNanos = Long.parseLong(startTimeStr);
+ endTimeNanos = Long.parseLong(endTimeStr);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ long durationNanos = endTimeNanos - startTimeNanos;
+
+ String parentSpanId = optString(root, "parentSpanId"); //$NON-NLS-1$
+ String serviceName = optString(root, "serviceName"); //$NON-NLS-1$
+ if (serviceName == null) {
+ serviceName = ""; //$NON-NLS-1$
+ }
+
+ return new SpanHeader(name, traceId, spanId, startTimeNanos,
+ endTimeNanos, durationNanos, parentSpanId, serviceName);
+ }
+
+ /**
+ * Parse an OTLP span JSON string into an OpenTracingField
+ *
+ * @param jsonString
+ * the OTLP span JSON
+ * @return an OpenTracingField, or null if parsing fails
+ */
+ public static @Nullable OpenTracingField parseJson(String jsonString) {
+ JsonObject root = G_SON.fromJson(jsonString, JsonObject.class);
+ if (root == null) {
+ return null;
+ }
+
+ SpanHeader header = parseHeader(root);
+ if (header == null) {
+ return null;
+ }
+
+ // Convert to Jaeger-compatible JSON and delegate to OpenTracingField
+ String jaegerJson = toJaegerJson(root, header.name, header.traceId, header.spanId,
+ header.startTimeNanos, header.durationNanos, header.parentSpanId, header.serviceName);
+
+ // Build a process field for OpenTracingField
+ JsonObject processObj = new JsonObject();
+ processObj.addProperty("serviceName", header.serviceName); //$NON-NLS-1$
+ String processField = G_SON.toJson(processObj);
+ return OpenTracingField.parseJson(jaegerJson, processField);
+ }
+
+ /**
+ * Parse an OTLP span JSON string into a native {@link OtlpSpan} model that
+ * preserves all OTLP-specific fields.
+ *
+ * @param jsonString
+ * the OTLP span JSON
+ * @return an OtlpSpan, or null if parsing fails
+ */
+ public static @Nullable OtlpSpan parseOtlpSpan(String jsonString) {
+ JsonObject root = G_SON.fromJson(jsonString, JsonObject.class);
+ if (root == null) {
+ return null;
+ }
+
+ SpanHeader header = parseHeader(root);
+ if (header == null) {
+ return null;
+ }
+
+ // Parse kind
+ int kindValue = 0;
+ JsonElement kindEl = root.get("kind"); //$NON-NLS-1$
+ if (kindEl != null && !kindEl.isJsonNull()) {
+ kindValue = kindEl.getAsInt();
+ }
+
+ // Parse status
+ OtlpSpanStatus status = parseStatus(root);
+
+ // Parse attributes
+ Map<@NonNull String, @NonNull String> attributes = parseAttributes(optJSONArray(root, "attributes")); //$NON-NLS-1$
+
+ // Parse events
+ List<@NonNull OtlpSpanEvent> events = parseSpanEvents(optJSONArray(root, "events")); //$NON-NLS-1$
+
+ // Parse links
+ List<@NonNull OtlpSpanLink> links = parseSpanLinks(optJSONArray(root, "links")); //$NON-NLS-1$
+
+ // Parse dropped counts
+ int droppedAttributesCount = optInt(root, "droppedAttributesCount"); //$NON-NLS-1$
+ int droppedEventsCount = optInt(root, "droppedEventsCount"); //$NON-NLS-1$
+ int droppedLinksCount = optInt(root, "droppedLinksCount"); //$NON-NLS-1$
+
+ // Parse instrumentation scope (if injected)
+ String scopeName = optString(root, "instrumentationScopeName"); //$NON-NLS-1$
+ String scopeVersion = optString(root, "instrumentationScopeVersion"); //$NON-NLS-1$
+
+ // Parse resource attributes
+ Map<@NonNull String, @NonNull String> resourceAttributes = parseAttributes(optJSONArray(root, "resourceAttributes")); //$NON-NLS-1$
+
+ return new OtlpSpan.Builder()
+ .traceId(header.traceId)
+ .spanId(header.spanId)
+ .parentSpanId(header.parentSpanId)
+ .operationName(header.name)
+ .startTimeNanos(header.startTimeNanos)
+ .durationNanos(header.durationNanos)
+ .serviceName(header.serviceName)
+ .kind(OtlpSpanKind.fromValue(kindValue))
+ .status(status)
+ .attributes(attributes)
+ .resourceAttributes(resourceAttributes)
+ .events(events)
+ .links(links)
+ .instrumentationScopeName(scopeName)
+ .instrumentationScopeVersion(scopeVersion)
+ .droppedAttributesCount(droppedAttributesCount)
+ .droppedEventsCount(droppedEventsCount)
+ .droppedLinksCount(droppedLinksCount)
+ .build();
+ }
+
+ /**
+ * Convert OTLP span data to Jaeger-compatible JSON string that
+ * OpenTracingField.parseJson can handle.
+ */
+ private static String toJaegerJson(JsonObject otlpRoot,
+ String name, String traceId, String spanId, long startTimeNanos,
+ long durationNanos, @Nullable String parentSpanId, String serviceName) {
+ JsonObject jaeger = new JsonObject();
+ jaeger.addProperty(IOpenTracingConstants.OPERATION_NAME, name);
+ jaeger.addProperty(IOpenTracingConstants.TRACE_ID, traceId);
+ jaeger.addProperty(IOpenTracingConstants.SPAN_ID, spanId);
+ // Jaeger uses microseconds
+ jaeger.addProperty(IOpenTracingConstants.START_TIME, startTimeNanos / 1000);
+ jaeger.addProperty(IOpenTracingConstants.DURATION, durationNanos / 1000);
+
+ // References
+ if (parentSpanId != null && !parentSpanId.isEmpty()) {
+ JsonArray refs = new JsonArray();
+ JsonObject ref = new JsonObject();
+ ref.addProperty(IOpenTracingConstants.REFERENCE_TYPE, "CHILD_OF"); //$NON-NLS-1$
+ ref.addProperty(IOpenTracingConstants.SPAN_ID, parentSpanId);
+ refs.add(ref);
+ jaeger.add(IOpenTracingConstants.REFERENCES, refs);
+ }
+
+ // Tags from OTLP attributes
+ JsonArray attributes = optJSONArray(otlpRoot, "attributes"); //$NON-NLS-1$
+ JsonArray tags = new JsonArray();
+ if (attributes != null) {
+ for (int i = 0; i < attributes.size(); i++) {
+ JsonObject attr = attributes.get(i).getAsJsonObject();
+ JsonElement keyEl = attr.get("key"); //$NON-NLS-1$
+ if (keyEl == null || keyEl.isJsonNull()) {
+ continue;
+ }
+ String key = keyEl.getAsString();
+ JsonObject valueObj = attr.getAsJsonObject("value"); //$NON-NLS-1$
+ String value = extractAttributeValue(valueObj);
+ JsonObject tag = new JsonObject();
+ tag.addProperty(IOpenTracingConstants.KEY, key);
+ tag.addProperty(IOpenTracingConstants.VALUE, value);
+ tags.add(tag);
+ }
+ }
+ // Add resource attributes as resource.* tags
+ JsonArray resourceAttributes = optJSONArray(otlpRoot, "resourceAttributes"); //$NON-NLS-1$
+ if (resourceAttributes != null) {
+ for (int i = 0; i < resourceAttributes.size(); i++) {
+ JsonObject attr = resourceAttributes.get(i).getAsJsonObject();
+ JsonElement keyEl = attr.get("key"); //$NON-NLS-1$
+ if (keyEl == null || keyEl.isJsonNull()) {
+ continue;
+ }
+ String key = keyEl.getAsString();
+ JsonObject valueObj = attr.getAsJsonObject("value"); //$NON-NLS-1$
+ String value = extractAttributeValue(valueObj);
+ JsonObject tag = new JsonObject();
+ tag.addProperty(IOpenTracingConstants.KEY, "resource." + key); //$NON-NLS-1$
+ tag.addProperty(IOpenTracingConstants.VALUE, value);
+ tags.add(tag);
+ }
+ }
+ // Add service.name as a tag
+ if (!serviceName.isEmpty()) {
+ JsonObject svcTag = new JsonObject();
+ svcTag.addProperty(IOpenTracingConstants.KEY, "service.name"); //$NON-NLS-1$
+ svcTag.addProperty(IOpenTracingConstants.VALUE, serviceName);
+ tags.add(svcTag);
+ }
+ if (tags.size() > 0) {
+ jaeger.add(IOpenTracingConstants.TAGS, tags);
+ }
+
+ // Process ID - use service name
+ jaeger.addProperty(IOpenTracingConstants.PROCESS_ID, serviceName);
+
+ // Map OTLP span events to Jaeger logs
+ JsonArray events = optJSONArray(otlpRoot, "events"); //$NON-NLS-1$
+ if (events != null && events.size() > 0) {
+ JsonArray logs = new JsonArray();
+ for (int i = 0; i < events.size(); i++) {
+ JsonObject eventObj = events.get(i).getAsJsonObject();
+ JsonObject log = new JsonObject();
+
+ // Convert timeUnixNano to microseconds for Jaeger
+ long timeNano = 0;
+ JsonElement timeEl = eventObj.get("timeUnixNano"); //$NON-NLS-1$
+ if (timeEl != null && !timeEl.isJsonNull()) {
+ try {
+ timeNano = Long.parseLong(timeEl.getAsString());
+ } catch (NumberFormatException e) {
+ timeNano = 0;
+ }
+ }
+ log.addProperty(IOpenTracingConstants.TIMESTAMP, timeNano / 1000);
+
+ // Build fields array: first field is always "event" -> name
+ JsonArray fields = new JsonArray();
+ JsonObject eventField = new JsonObject();
+ eventField.addProperty(IOpenTracingConstants.KEY, "event"); //$NON-NLS-1$
+ String eventName = ""; //$NON-NLS-1$
+ JsonElement nameEl = eventObj.get("name"); //$NON-NLS-1$
+ if (nameEl != null && !nameEl.isJsonNull()) {
+ eventName = nameEl.getAsString();
+ }
+ eventField.addProperty(IOpenTracingConstants.VALUE, eventName);
+ fields.add(eventField);
+
+ // Add event attributes as additional fields
+ JsonArray eventAttrs = optJSONArray(eventObj, "attributes"); //$NON-NLS-1$
+ if (eventAttrs != null) {
+ for (int j = 0; j < eventAttrs.size(); j++) {
+ JsonObject attr = eventAttrs.get(j).getAsJsonObject();
+ JsonElement attrKeyEl = attr.get("key"); //$NON-NLS-1$
+ if (attrKeyEl == null || attrKeyEl.isJsonNull()) {
+ continue;
+ }
+ String key = attrKeyEl.getAsString();
+ JsonObject valueObj = attr.getAsJsonObject("value"); //$NON-NLS-1$
+ String value = extractAttributeValue(valueObj);
+ JsonObject field = new JsonObject();
+ field.addProperty(IOpenTracingConstants.KEY, key);
+ field.addProperty(IOpenTracingConstants.VALUE, value);
+ fields.add(field);
+ }
+ }
+ log.add(IOpenTracingConstants.FIELDS, fields);
+ logs.add(log);
+ }
+ jaeger.add(IOpenTracingConstants.LOGS, logs);
+ }
+
+ return G_SON.toJson(jaeger);
+ }
+
+ private static OtlpSpanStatus parseStatus(JsonObject root) {
+ JsonElement statusEl = root.get("status"); //$NON-NLS-1$
+ if (statusEl == null || !statusEl.isJsonObject()) {
+ return new OtlpSpanStatus(OtlpSpanStatus.STATUS_CODE_UNSET, null);
+ }
+ JsonObject statusObj = statusEl.getAsJsonObject();
+ int code = OtlpSpanStatus.STATUS_CODE_UNSET;
+ JsonElement codeEl = statusObj.get("code"); //$NON-NLS-1$
+ if (codeEl != null && !codeEl.isJsonNull()) {
+ code = codeEl.getAsInt();
+ }
+ String message = null;
+ JsonElement msgEl = statusObj.get("message"); //$NON-NLS-1$
+ if (msgEl != null && !msgEl.isJsonNull()) {
+ message = msgEl.getAsString();
+ }
+ return new OtlpSpanStatus(code, message);
+ }
+
+ private static @NonNull Map<@NonNull String, @NonNull String> parseAttributes(@Nullable JsonArray attributes) {
+ if (attributes == null || attributes.size() == 0) {
+ return new HashMap<>();
+ }
+ Map<@NonNull String, @NonNull String> result = new HashMap<>();
+ for (int i = 0; i < attributes.size(); i++) {
+ JsonObject attr = attributes.get(i).getAsJsonObject();
+ JsonElement keyEl = attr.get("key"); //$NON-NLS-1$
+ if (keyEl == null) {
+ continue;
+ }
+ String key = keyEl.getAsString();
+ JsonObject valueObj = attr.getAsJsonObject("value"); //$NON-NLS-1$
+ String value = extractAttributeValue(valueObj);
+ result.put(key, value);
+ }
+ return result;
+ }
+
+ private static @NonNull List<@NonNull OtlpSpanEvent> parseSpanEvents(@Nullable JsonArray events) {
+ if (events == null || events.size() == 0) {
+ return new ArrayList<>();
+ }
+ List<@NonNull OtlpSpanEvent> result = new ArrayList<>();
+ for (int i = 0; i < events.size(); i++) {
+ JsonObject eventObj = events.get(i).getAsJsonObject();
+ String eventName = ""; //$NON-NLS-1$
+ JsonElement nameEl = eventObj.get("name"); //$NON-NLS-1$
+ if (nameEl != null && !nameEl.isJsonNull()) {
+ eventName = nameEl.getAsString();
+ }
+ long timeUnixNano = 0;
+ JsonElement timeEl = eventObj.get("timeUnixNano"); //$NON-NLS-1$
+ if (timeEl != null && !timeEl.isJsonNull()) {
+ try {
+ timeUnixNano = Long.parseLong(timeEl.getAsString());
+ } catch (NumberFormatException e) {
+ timeUnixNano = 0;
+ }
+ }
+ Map<@NonNull String, @NonNull String> attrs = parseAttributes(optJSONArray(eventObj, "attributes")); //$NON-NLS-1$
+ result.add(new OtlpSpanEvent(eventName, timeUnixNano, attrs));
+ }
+ return result;
+ }
+
+ private static @NonNull List<@NonNull OtlpSpanLink> parseSpanLinks(@Nullable JsonArray links) {
+ if (links == null || links.size() == 0) {
+ return new ArrayList<>();
+ }
+ List<@NonNull OtlpSpanLink> result = new ArrayList<>();
+ for (int i = 0; i < links.size(); i++) {
+ JsonObject linkObj = links.get(i).getAsJsonObject();
+ String linkTraceId = ""; //$NON-NLS-1$
+ JsonElement traceIdEl = linkObj.get("traceId"); //$NON-NLS-1$
+ if (traceIdEl != null && !traceIdEl.isJsonNull()) {
+ linkTraceId = traceIdEl.getAsString();
+ }
+ String linkSpanId = ""; //$NON-NLS-1$
+ JsonElement spanIdEl = linkObj.get("spanId"); //$NON-NLS-1$
+ if (spanIdEl != null && !spanIdEl.isJsonNull()) {
+ linkSpanId = spanIdEl.getAsString();
+ }
+ String traceState = null;
+ JsonElement stateEl = linkObj.get("traceState"); //$NON-NLS-1$
+ if (stateEl != null && !stateEl.isJsonNull()) {
+ traceState = stateEl.getAsString();
+ }
+ Map<@NonNull String, @NonNull String> attrs = parseAttributes(optJSONArray(linkObj, "attributes")); //$NON-NLS-1$
+ result.add(new OtlpSpanLink(linkTraceId, linkSpanId, attrs, traceState));
+ }
+ return result;
+ }
+
+ private static String extractAttributeValue(JsonObject valueObj) {
+ if (valueObj == null) {
+ return ""; //$NON-NLS-1$
+ }
+ if (valueObj.has("stringValue")) { //$NON-NLS-1$
+ return valueObj.get("stringValue").getAsString(); //$NON-NLS-1$
+ }
+ if (valueObj.has("intValue")) { //$NON-NLS-1$
+ return valueObj.get("intValue").getAsString(); //$NON-NLS-1$
+ }
+ if (valueObj.has("boolValue")) { //$NON-NLS-1$
+ return valueObj.get("boolValue").getAsString(); //$NON-NLS-1$
+ }
+ if (valueObj.has("doubleValue")) { //$NON-NLS-1$
+ return valueObj.get("doubleValue").getAsString(); //$NON-NLS-1$
+ }
+ if (valueObj.has("arrayValue")) { //$NON-NLS-1$
+ return valueObj.get("arrayValue").toString(); //$NON-NLS-1$
+ }
+ return valueObj.toString();
+ }
+
+ private static @Nullable String optString(JsonObject root, String key) {
+ JsonElement el = root.get(key);
+ return el != null && !el.isJsonNull() ? el.getAsString() : null;
+ }
+
+ private static int optInt(JsonObject root, String key) {
+ JsonElement el = root.get(key);
+ return el != null && !el.isJsonNull() ? el.getAsInt() : 0;
+ }
+
+ private static @Nullable JsonArray optJSONArray(JsonObject root, String key) {
+ JsonElement el = root.get(key);
+ return el != null && el.isJsonArray() ? el.getAsJsonArray() : null;
+ }
+}
diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufParser.java b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufParser.java
new file mode 100644
index 000000000..c9d6bc04f
--- /dev/null
+++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufParser.java
@@ -0,0 +1,801 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Ericsson
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License 2.0 which
+ * accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+
+package org.eclipse.tracecompass.incubator.internal.otlp.core.trace;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.WireFormat;
+
+/**
+ * Low-level parser for OTLP protobuf binary trace files
+ * (ExportTraceServiceRequest). Parses the wire format directly using
+ * {@link CodedInputStream}, without generated proto stubs.
+ *
+ * The parser produces a list of {@link JsonObject} spans in the same flat
+ * format as the JSON sorting job, so they can feed directly into
+ * {@link OtlpField#parseJson(String)}.
+ *
+ * @author Matthew Khouzam
+ */
+public class OtlpProtobufParser {
+
+ private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); //$NON-NLS-1$
+
+ private OtlpProtobufParser() {
+ // utility class
+ }
+
+ /**
+ * Parse an OTLP protobuf binary file and return all spans as JsonObjects.
+ *
+ * @param path
+ * path to the .pb file
+ * @return list of span JsonObjects in flat OTLP JSON format
+ * @throws IOException
+ * if reading fails
+ */
+ public static @NonNull List<@NonNull JsonObject> parse(String path) throws IOException {
+ List<@NonNull JsonObject> allSpans = new ArrayList<>();
+ try (FileInputStream fis = new FileInputStream(path)) {
+ CodedInputStream cis = CodedInputStream.newInstance(fis);
+ cis.setSizeLimit(Integer.MAX_VALUE);
+ parseExportTraceServiceRequest(cis, allSpans);
+ }
+ return allSpans;
+ }
+
+ /**
+ * Parse ExportTraceServiceRequest: repeated ResourceSpans resource_spans =
+ * 1
+ */
+ private static void parseExportTraceServiceRequest(CodedInputStream cis, List<@NonNull JsonObject> allSpans) throws IOException {
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ if (fieldNumber == 1 && wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ parseResourceSpans(cis, allSpans);
+ cis.popLimit(oldLimit);
+ } else {
+ skipField(cis, wireType);
+ }
+ }
+ }
+
+ /**
+ * Parse ResourceSpans message
+ */
+ private static void parseResourceSpans(CodedInputStream cis, List<@NonNull JsonObject> allSpans) throws IOException {
+ JsonArray resourceAttributes = new JsonArray();
+ String serviceName = ""; //$NON-NLS-1$
+ List<@NonNull JsonObject> scopeSpansList = new ArrayList<>();
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // Resource resource = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ parseResource(cis, resourceAttributes);
+ cis.popLimit(oldLimit);
+ // Extract service.name from resource attributes
+ serviceName = extractServiceName(resourceAttributes);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // repeated ScopeSpans scope_spans = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ parseScopeSpans(cis, scopeSpansList);
+ cis.popLimit(oldLimit);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ // Inject resource info into each span
+ for (JsonObject span : scopeSpansList) {
+ span.addProperty("serviceName", serviceName); //$NON-NLS-1$
+ if (resourceAttributes.size() > 0) {
+ span.add("resourceAttributes", resourceAttributes.deepCopy()); //$NON-NLS-1$
+ }
+ allSpans.add(span);
+ }
+ }
+
+ /**
+ * Parse Resource message: repeated KeyValue attributes = 1
+ */
+ private static void parseResource(CodedInputStream cis, JsonArray resourceAttributes) throws IOException {
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ if (fieldNumber == 1 && wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kv = parseKeyValue(cis);
+ cis.popLimit(oldLimit);
+ if (kv != null) {
+ resourceAttributes.add(kv);
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ }
+ }
+
+ /**
+ * Parse ScopeSpans message
+ */
+ private static void parseScopeSpans(CodedInputStream cis, List<@NonNull JsonObject> spans) throws IOException {
+ String scopeName = null;
+ String scopeVersion = null;
+
+ // We need to buffer spans because scope info comes in field 1,
+ // but spans are in field 2
+ List<@NonNull JsonObject> localSpans = new ArrayList<>();
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // InstrumentationScope scope = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ String[] scopeInfo = parseInstrumentationScope(cis);
+ cis.popLimit(oldLimit);
+ scopeName = scopeInfo[0];
+ scopeVersion = scopeInfo[1];
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // repeated Span spans = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject span = parseSpan(cis);
+ cis.popLimit(oldLimit);
+ localSpans.add(span);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ // Inject scope info into spans
+ for (JsonObject span : localSpans) {
+ if (scopeName != null) {
+ span.addProperty("instrumentationScopeName", scopeName); //$NON-NLS-1$
+ }
+ if (scopeVersion != null) {
+ span.addProperty("instrumentationScopeVersion", scopeVersion); //$NON-NLS-1$
+ }
+ spans.add(span);
+ }
+ }
+
+ /**
+ * Parse InstrumentationScope: name=1, version=2
+ *
+ * @return [name, version]
+ */
+ private static String[] parseInstrumentationScope(CodedInputStream cis) throws IOException {
+ String name = null;
+ String version = null;
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // string name = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ name = cis.readStringRequireUtf8();
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // string version = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ version = cis.readStringRequireUtf8();
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+ return new String[] { name, version };
+ }
+
+ /**
+ * Parse a Span message into a flat JsonObject
+ */
+ private static @NonNull JsonObject parseSpan(CodedInputStream cis) throws IOException {
+ JsonObject span = new JsonObject();
+ JsonArray attributes = new JsonArray();
+ JsonArray events = new JsonArray();
+ JsonArray links = new JsonArray();
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // bytes trace_id = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ span.addProperty("traceId", bytesToHex(bs.toByteArray())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // bytes span_id = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ span.addProperty("spanId", bytesToHex(bs.toByteArray())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 3: // string trace_state = 3
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ span.addProperty("traceState", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 4: // bytes parent_span_id = 4
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ byte[] bytes = bs.toByteArray();
+ if (bytes.length > 0) {
+ span.addProperty("parentSpanId", bytesToHex(bytes)); //$NON-NLS-1$
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 5: // string name = 5
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ span.addProperty("name", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 6: // SpanKind kind = 6 (enum/varint)
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ span.addProperty("kind", cis.readEnum()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 7: // fixed64 start_time_unix_nano = 7
+ if (wireType == WireFormat.WIRETYPE_FIXED64) {
+ long startTime = cis.readFixed64();
+ span.addProperty("startTimeUnixNano", String.valueOf(startTime)); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 8: // fixed64 end_time_unix_nano = 8
+ if (wireType == WireFormat.WIRETYPE_FIXED64) {
+ long endTime = cis.readFixed64();
+ span.addProperty("endTimeUnixNano", String.valueOf(endTime)); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 9: // repeated KeyValue attributes = 9
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kv = parseKeyValue(cis);
+ cis.popLimit(oldLimit);
+ if (kv != null) {
+ attributes.add(kv);
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 10: // uint32 dropped_attributes_count = 10
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ span.addProperty("droppedAttributesCount", cis.readUInt32()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 11: // repeated Event events = 11
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject event = parseEvent(cis);
+ cis.popLimit(oldLimit);
+ events.add(event);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 12: // uint32 dropped_events_count = 12
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ span.addProperty("droppedEventsCount", cis.readUInt32()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 13: // repeated Link links = 13
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject link = parseLink(cis);
+ cis.popLimit(oldLimit);
+ links.add(link);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 14: // uint32 dropped_links_count = 14
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ span.addProperty("droppedLinksCount", cis.readUInt32()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 15: // Status status = 15
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject status = parseStatus(cis);
+ cis.popLimit(oldLimit);
+ span.add("status", status); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ if (attributes.size() > 0) {
+ span.add("attributes", attributes); //$NON-NLS-1$
+ }
+ if (events.size() > 0) {
+ span.add("events", events); //$NON-NLS-1$
+ }
+ if (links.size() > 0) {
+ span.add("links", links); //$NON-NLS-1$
+ }
+ return span;
+ }
+
+ /**
+ * Parse Event message
+ */
+ private static @NonNull JsonObject parseEvent(CodedInputStream cis) throws IOException {
+ JsonObject event = new JsonObject();
+ JsonArray attributes = new JsonArray();
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // fixed64 time_unix_nano = 1
+ if (wireType == WireFormat.WIRETYPE_FIXED64) {
+ long timeNano = cis.readFixed64();
+ event.addProperty("timeUnixNano", String.valueOf(timeNano)); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // string name = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ event.addProperty("name", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 3: // repeated KeyValue attributes = 3
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kv = parseKeyValue(cis);
+ cis.popLimit(oldLimit);
+ if (kv != null) {
+ attributes.add(kv);
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 4: // uint32 dropped_attributes_count = 4
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ event.addProperty("droppedAttributesCount", cis.readUInt32()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ if (attributes.size() > 0) {
+ event.add("attributes", attributes); //$NON-NLS-1$
+ }
+ return event;
+ }
+
+ /**
+ * Parse Link message
+ */
+ private static @NonNull JsonObject parseLink(CodedInputStream cis) throws IOException {
+ JsonObject link = new JsonObject();
+ JsonArray attributes = new JsonArray();
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // bytes trace_id = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ link.addProperty("traceId", bytesToHex(bs.toByteArray())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // bytes span_id = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ link.addProperty("spanId", bytesToHex(bs.toByteArray())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 3: // string trace_state = 3
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ link.addProperty("traceState", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 4: // repeated KeyValue attributes = 4
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kv = parseKeyValue(cis);
+ cis.popLimit(oldLimit);
+ if (kv != null) {
+ attributes.add(kv);
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 5: // uint32 dropped_attributes_count = 5
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ link.addProperty("droppedAttributesCount", cis.readUInt32()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ if (attributes.size() > 0) {
+ link.add("attributes", attributes); //$NON-NLS-1$
+ }
+ return link;
+ }
+
+ /**
+ * Parse Status message: message=2, code=3
+ */
+ private static @NonNull JsonObject parseStatus(CodedInputStream cis) throws IOException {
+ JsonObject status = new JsonObject();
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 2: // string message = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ status.addProperty("message", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 3: // StatusCode code = 3 (enum/varint)
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ status.addProperty("code", cis.readEnum()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+ return status;
+ }
+
+ /**
+ * Parse KeyValue message: key=1 (string), value=2 (AnyValue)
+ */
+ private static @Nullable JsonObject parseKeyValue(CodedInputStream cis) throws IOException {
+ String key = null;
+ JsonObject value = null;
+
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // string key = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ key = cis.readStringRequireUtf8();
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // AnyValue value = 2
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ value = parseAnyValue(cis);
+ cis.popLimit(oldLimit);
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+
+ if (key == null) {
+ return null;
+ }
+ JsonObject kv = new JsonObject();
+ kv.addProperty("key", key); //$NON-NLS-1$
+ if (value != null) {
+ kv.add("value", value); //$NON-NLS-1$
+ }
+ return kv;
+ }
+
+ /**
+ * Parse AnyValue (oneof): string_value=1, bool_value=2, int_value=3,
+ * double_value=4, array_value=5, kvlist_value=6, bytes_value=7
+ */
+ private static @NonNull JsonObject parseAnyValue(CodedInputStream cis) throws IOException {
+ JsonObject anyValue = new JsonObject();
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ switch (fieldNumber) {
+ case 1: // string string_value = 1
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ anyValue.addProperty("stringValue", cis.readStringRequireUtf8()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 2: // bool bool_value = 2
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ anyValue.addProperty("boolValue", cis.readBool()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 3: // int64 int_value = 3
+ if (wireType == WireFormat.WIRETYPE_VARINT) {
+ anyValue.addProperty("intValue", String.valueOf(cis.readInt64())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 4: // double double_value = 4
+ if (wireType == WireFormat.WIRETYPE_FIXED64) {
+ anyValue.addProperty("doubleValue", cis.readDouble()); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 5: // ArrayValue array_value = 5
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject arrayValue = parseArrayValue(cis);
+ cis.popLimit(oldLimit);
+ anyValue.add("arrayValue", arrayValue); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 6: // KeyValueList kvlist_value = 6
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kvList = parseKeyValueList(cis);
+ cis.popLimit(oldLimit);
+ anyValue.add("kvlistValue", kvList); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ case 7: // bytes bytes_value = 7
+ if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ ByteString bs = cis.readBytes();
+ anyValue.addProperty("bytesValue", bytesToHex(bs.toByteArray())); //$NON-NLS-1$
+ } else {
+ skipField(cis, wireType);
+ }
+ break;
+ default:
+ skipField(cis, wireType);
+ break;
+ }
+ }
+ return anyValue;
+ }
+
+ /**
+ * Parse ArrayValue: repeated AnyValue values = 1
+ */
+ private static @NonNull JsonObject parseArrayValue(CodedInputStream cis) throws IOException {
+ JsonObject arrayValue = new JsonObject();
+ JsonArray values = new JsonArray();
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ if (fieldNumber == 1 && wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject val = parseAnyValue(cis);
+ cis.popLimit(oldLimit);
+ values.add(val);
+ } else {
+ skipField(cis, wireType);
+ }
+ }
+ arrayValue.add("values", values); //$NON-NLS-1$
+ return arrayValue;
+ }
+
+ /**
+ * Parse KeyValueList: repeated KeyValue values = 1
+ */
+ private static @NonNull JsonObject parseKeyValueList(CodedInputStream cis) throws IOException {
+ JsonObject kvList = new JsonObject();
+ JsonArray values = new JsonArray();
+ while (!cis.isAtEnd()) {
+ int tag = cis.readTag();
+ int fieldNumber = WireFormat.getTagFieldNumber(tag);
+ int wireType = WireFormat.getTagWireType(tag);
+ if (fieldNumber == 1 && wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+ int length = cis.readRawVarint32();
+ int oldLimit = cis.pushLimit(length);
+ JsonObject kv = parseKeyValue(cis);
+ cis.popLimit(oldLimit);
+ if (kv != null) {
+ values.add(kv);
+ }
+ } else {
+ skipField(cis, wireType);
+ }
+ }
+ kvList.add("values", values); //$NON-NLS-1$
+ return kvList;
+ }
+
+ /**
+ * Extract service.name from resource attributes
+ */
+ private static String extractServiceName(JsonArray resourceAttributes) {
+ for (int i = 0; i < resourceAttributes.size(); i++) {
+ JsonObject attr = resourceAttributes.get(i).getAsJsonObject();
+ JsonElement keyEl = attr.get("key"); //$NON-NLS-1$
+ if (keyEl == null || keyEl.isJsonNull()) {
+ continue;
+ }
+ if ("service.name".equals(keyEl.getAsString())) { //$NON-NLS-1$
+ JsonObject value = attr.getAsJsonObject("value"); //$NON-NLS-1$
+ if (value != null && value.has("stringValue")) { //$NON-NLS-1$
+ return value.get("stringValue").getAsString(); //$NON-NLS-1$
+ }
+ }
+ }
+ return ""; //$NON-NLS-1$
+ }
+
+ /**
+ * Skip an unknown field based on its wire type
+ */
+ private static void skipField(CodedInputStream cis, int wireType) throws IOException {
+ switch (wireType) {
+ case WireFormat.WIRETYPE_VARINT:
+ cis.readRawVarint64();
+ break;
+ case WireFormat.WIRETYPE_FIXED64:
+ cis.readFixed64();
+ break;
+ case WireFormat.WIRETYPE_LENGTH_DELIMITED:
+ int length = cis.readRawVarint32();
+ cis.skipRawBytes(length);
+ break;
+ case WireFormat.WIRETYPE_FIXED32:
+ cis.readFixed32();
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Convert a byte array to a lowercase hex string
+ */
+ private static String bytesToHex(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for (int i = 0; i < bytes.length; i++) {
+ int v = bytes[i] & 0xFF;
+ hexChars[i * 2] = HEX_CHARS[v >>> 4];
+ hexChars[i * 2 + 1] = HEX_CHARS[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+}
diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufSortingJob.java b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufSortingJob.java
new file mode 100644
index 000000000..4fea58556
--- /dev/null
+++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpProtobufSortingJob.java
@@ -0,0 +1,115 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Ericsson
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License 2.0 which
+ * accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+
+package org.eclipse.tracecompass.incubator.internal.otlp.core.trace;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Comparator;
+import java.util.List;
+
+import org.eclipse.core.runtime.IProgressMonitor;
+import org.eclipse.core.runtime.IStatus;
+import org.eclipse.core.runtime.Status;
+import org.eclipse.core.runtime.jobs.Job;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.tracecompass.incubator.internal.otlp.core.Activator;
+import org.eclipse.tracecompass.tmf.core.trace.ITmfTrace;
+import org.eclipse.tracecompass.tmf.core.trace.TmfTraceManager;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * Sorting job for OTLP protobuf binary traces. Parses the protobuf file using
+ * {@link OtlpProtobufParser}, sorts spans by startTimeUnixNano, and writes the
+ * same sorted JSON format as {@link OtlpSortingJob}.
+ *
+ * @author Matthew Khouzam
+ */
+public class OtlpProtobufSortingJob extends Job {
+
+ private static final Gson G_SON = new Gson();
+
+ private final ITmfTrace fTrace;
+ private final String fPath;
+
+ /**
+ * Constructor
+ *
+ * @param trace
+ * the trace
+ * @param path
+ * path to the OTLP protobuf file
+ */
+ public OtlpProtobufSortingJob(ITmfTrace trace, String path) {
+ super("Sorting OTLP protobuf trace"); //$NON-NLS-1$
+ fTrace = trace;
+ fPath = path;
+ }
+
+ @Override
+ protected IStatus run(IProgressMonitor monitor) {
+ try {
+ List<@NonNull JsonObject> allSpans = OtlpProtobufParser.parse(fPath);
+
+ if (allSpans.isEmpty()) {
+ return new Status(IStatus.ERROR, Activator.PLUGIN_ID, "No spans found in protobuf file"); //$NON-NLS-1$
+ }
+
+ // Extract span events as individual entries
+ List<@NonNull JsonObject> allEntries = new java.util.ArrayList<>(allSpans);
+ for (JsonObject span : allSpans) {
+ OtlpSortingJob.extractSpanEvents(span, allEntries);
+ }
+
+ // Sort by startTimeUnixNano
+ allEntries.sort(Comparator.comparingLong(span -> {
+ JsonElement el = span.get("startTimeUnixNano"); //$NON-NLS-1$
+ return el != null && !el.isJsonNull() ? parseLongSafe(el.getAsString()) : 0L;
+ }));
+
+ // Write sorted spans as a JSON array (one object per line)
+ String dir = TmfTraceManager.getSupplementaryFileDir(fTrace);
+ File outFile = new File(dir + new File(fPath).getName());
+ outFile.getParentFile().mkdirs();
+ try (PrintWriter writer = new PrintWriter(outFile)) {
+ writer.println('[');
+ for (int i = 0; i < allEntries.size(); i++) {
+ writer.print(G_SON.toJson(allEntries.get(i)));
+ if (i < allEntries.size() - 1) {
+ writer.println(',');
+ } else {
+ writer.println();
+ }
+ }
+ writer.println(']');
+ if (writer.checkError()) {
+ return new Status(IStatus.ERROR, Activator.PLUGIN_ID, "Error writing sorted OTLP protobuf trace"); //$NON-NLS-1$
+ }
+ }
+
+ return Status.OK_STATUS;
+ } catch (IOException e) {
+ return new Status(IStatus.ERROR, Activator.PLUGIN_ID, "Error parsing OTLP protobuf trace", e); //$NON-NLS-1$
+ }
+ }
+
+ private static long parseLongSafe(String s) {
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException e) {
+ return 0L;
+ }
+ }
+}
diff --git a/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpSortingJob.java b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpSortingJob.java
new file mode 100644
index 000000000..bdeeddd0a
--- /dev/null
+++ b/tracetypes/org.eclipse.tracecompass.incubator.otlp.core/src/org/eclipse/tracecompass/incubator/internal/otlp/core/trace/OtlpSortingJob.java
@@ -0,0 +1,374 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Ericsson
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License 2.0 which
+ * accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+
+package org.eclipse.tracecompass.incubator.internal.otlp.core.trace;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.eclipse.core.runtime.IProgressMonitor;
+import org.eclipse.core.runtime.IStatus;
+import org.eclipse.core.runtime.Status;
+import org.eclipse.core.runtime.jobs.Job;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.tracecompass.incubator.internal.otlp.core.Activator;
+import org.eclipse.tracecompass.tmf.core.trace.ITmfTrace;
+import org.eclipse.tracecompass.tmf.core.trace.TmfTraceManager;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.stream.JsonReader;
+
+/**
+ * Sorting job for OTLP traces. Flattens the nested resourceSpans/scopeSpans
+ * structure into individual span JSON objects sorted by startTimeUnixNano.
+ * Supports both single JSON file and JSONL (one ExportTraceServiceRequest per
+ * line) formats.
+ *
+ * @author Matthew Khouzam
+ */
+public class OtlpSortingJob extends Job {
+
+ private static final Gson G_SON = new Gson();
+
+ private final ITmfTrace fTrace;
+ private final String fPath;
+
+ /**
+ * Constructor
+ *
+ * @param trace
+ * the trace
+ * @param path
+ * path to the OTLP JSON file
+ */
+ public OtlpSortingJob(ITmfTrace trace, String path) {
+ super("Sorting OTLP trace"); //$NON-NLS-1$
+ fTrace = trace;
+ fPath = path;
+ }
+
+ @Override
+ protected IStatus run(IProgressMonitor monitor) {
+ try {
+ List