diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index 1288d634a..7b7b8e8b6 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -51,7 +51,7 @@ android { } } -val otelVersion = "1.23.1" +val otelVersion = "1.24.0" val otelAlphaVersion = "$otelVersion-alpha" val otelInstrumentationVersion = "1.25.1" val otelInstrumentationAlphaVersion = "$otelInstrumentationVersion-alpha" @@ -73,5 +73,9 @@ dependencies { implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:$otelInstrumentationVersion") implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-semconv:$otelInstrumentationAlphaVersion") + implementation("io.opentelemetry:opentelemetry-api-events:$otelAlphaVersion") + implementation("io.opentelemetry:opentelemetry-sdk-logs:$otelAlphaVersion") + implementation("io.opentelemetry:opentelemetry-sdk:$otelAlphaVersion") + testImplementation("junit:junit:4.13.2") } diff --git a/sample-app/src/main/java/com/splunk/android/sample/SecondFragment.java b/sample-app/src/main/java/com/splunk/android/sample/SecondFragment.java index cb4b86cd4..57877697b 100644 --- a/sample-app/src/main/java/com/splunk/android/sample/SecondFragment.java +++ b/sample-app/src/main/java/com/splunk/android/sample/SecondFragment.java @@ -34,9 +34,13 @@ import com.splunk.rum.SplunkRum; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.events.EventEmitter; +import io.opentelemetry.api.events.EventEmitterProvider; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkEventEmitterProvider; import java.util.Random; import java.util.concurrent.Executors; @@ -104,6 +108,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { v -> { SplunkRum.getInstance() .addRumEvent("this span will be ignored", Attributes.empty()); + emitEvent(SplunkRum.getInstance(), "SecondFragment", "toWebViewClick"); NavHostFragment.findNavController(SecondFragment.this) .navigate(R.id.action_SecondFragment_to_webViewFragment); @@ -215,4 +220,16 @@ private void createSpamSpan() { .end(); updateLabel(); } + + public static void emitEvent(SplunkRum splunkRum, String eventDomain, String eventName) { + EventEmitterProvider eventEmitterProvider = + SdkEventEmitterProvider.create( + ((OpenTelemetrySdk) splunkRum.getOpenTelemetry()).getSdkLoggerProvider()); + EventEmitter eventEmitter = + eventEmitterProvider + .eventEmitterBuilder("test") + .setEventDomain(eventDomain) + .build(); + eventEmitter.emit(eventName, Attributes.empty()); + } } diff --git a/splunk-otel-android/src/main/java/com/splunk/rum/LogToSpanBridge.java b/splunk-otel-android/src/main/java/com/splunk/rum/LogToSpanBridge.java new file mode 100644 index 000000000..33b5cb37d --- /dev/null +++ b/splunk-otel-android/src/main/java/com/splunk/rum/LogToSpanBridge.java @@ -0,0 +1,122 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.rum; + +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerBuilder; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; +import io.opentelemetry.sdk.logs.data.Body; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; + +import java.util.concurrent.TimeUnit; + +final class LogToSpanBridge implements LogRecordProcessor { + + // can be used in Splunk code to set span names in instrumentations + static final AttributeKey OPERATION_NAME = stringKey("log.operation_name"); + + static final AttributeKey LOG_SEVERITY = longKey("log.severity"); + static final AttributeKey LOG_SEVERITY_TEXT = stringKey("log.severity_text"); + static final AttributeKey LOG_BODY = stringKey("log.body"); + + private volatile TracerProvider tracerProvider; + + void setTracerProvider(TracerProvider tracerProvider) { + this.tracerProvider = tracerProvider; + } + + @Override + public void onEmit(Context context, ReadWriteLogRecord logRecord) { + TracerProvider tracerProvider = this.tracerProvider; + if (tracerProvider == null) { + // if this is null then we've messed up the RumInitializer implementation + return; + } + + LogRecordData log = logRecord.toLogRecordData(); + Tracer tracer = getTracer(tracerProvider, log.getInstrumentationScopeInfo()); + + SpanBuilder spanBuilder = tracer.spanBuilder(getSpanName(log)); + setLogAttributes(spanBuilder, log); + Span span = + spanBuilder + .setStartTimestamp(log.getEpochNanos(), TimeUnit.NANOSECONDS) + .startSpan(); + span.end(log.getEpochNanos(), TimeUnit.NANOSECONDS); + } + + private static Tracer getTracer(TracerProvider tracerProvider, InstrumentationScopeInfo scope) { + TracerBuilder builder = tracerProvider.tracerBuilder(scope.getName()); + String version = scope.getVersion(); + if (version != null) { + builder.setInstrumentationVersion(version); + } + String schemaUrl = scope.getSchemaUrl(); + if (schemaUrl != null) { + builder.setSchemaUrl(schemaUrl); + } + return builder.build(); + } + + private static String getSpanName(LogRecordData log) { + String operationName = log.getAttributes().get(OPERATION_NAME); + if (operationName != null) { + return operationName; + } + String eventDomain = log.getAttributes().get(SemanticAttributes.EVENT_DOMAIN); + String eventName = log.getAttributes().get(SemanticAttributes.EVENT_NAME); + if (eventDomain != null || eventName != null) { + return (eventDomain == null ? "" : eventDomain + "/") + + (eventName == null ? "" : eventName); + } + return "Log"; + } + + private static void setLogAttributes(SpanBuilder spanBuilder, LogRecordData log) { + spanBuilder.setAllAttributes(log.getAttributes()); + int severity = log.getSeverity().getSeverityNumber(); + if (severity != Severity.UNDEFINED_SEVERITY_NUMBER.getSeverityNumber()) { + spanBuilder.setAttribute(LOG_SEVERITY, (long) severity); + } + String severityText = log.getSeverityText(); + if (severityText != null) { + spanBuilder.setAttribute(LOG_SEVERITY_TEXT, severityText); + } + Body logBody = log.getBody(); + switch (logBody.getType()) { + case STRING: + spanBuilder.setAttribute(LOG_BODY, logBody.asString()); + break; + + case EMPTY: + default: + break; + } + } +} diff --git a/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java b/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java index e3faf7e99..2083d28f0 100644 --- a/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java +++ b/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java @@ -188,6 +188,18 @@ SplunkRum initialize( return tracerProviderBuilder; }); + // install the log->span bridge + LogToSpanBridge logBridge = new LogToSpanBridge(); + otelRumBuilder.addLoggerProviderCustomizer( + (loggerProviderBuilder, app) -> + loggerProviderBuilder.addLogRecordProcessor(logBridge)); + // make sure the TracerProvider gets set as the very first thing, before any other + // instrumentations + otelRumBuilder.addInstrumentation( + instrumentedApplication -> + logBridge.setTracerProvider( + instrumentedApplication.getOpenTelemetrySdk().getTracerProvider())); + if (builder.isAnrDetectionEnabled()) { installAnrDetector(otelRumBuilder, mainLooper); } diff --git a/splunk-otel-android/src/test/java/com/splunk/rum/LogToSpanBridgeTest.java b/splunk-otel-android/src/test/java/com/splunk/rum/LogToSpanBridgeTest.java new file mode 100644 index 000000000..f8a501e2f --- /dev/null +++ b/splunk-otel-android/src/test/java/com/splunk/rum/LogToSpanBridgeTest.java @@ -0,0 +1,169 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.rum; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; +import io.opentelemetry.sdk.logs.data.Body; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class LogToSpanBridgeTest { + + @RegisterExtension OpenTelemetryExtension testing = OpenTelemetryExtension.create(); + + @Mock ReadWriteLogRecord logRecord; + @Mock LogRecordData log; + + final LogToSpanBridge bridge = new LogToSpanBridge(); + + @Test + void misconfiguration() { + bridge.onEmit(Context.root(), logRecord); + + assertThat(testing.getSpans()).isEmpty(); + } + + @Test + void unnamedLog() { + InstrumentationScopeInfo scope = + InstrumentationScopeInfo.builder("test") + .setVersion("1.2.3") + .setSchemaUrl("http://schema") + .build(); + long epochNanos = 123_456_789_000_000L; + when(log.getInstrumentationScopeInfo()).thenReturn(scope); + when(log.getAttributes()) + .thenReturn(Attributes.builder().put("attr1", "12").put("attr2", "42").build()); + when(log.getEpochNanos()).thenReturn(epochNanos); + when(log.getSeverity()).thenReturn(Severity.DEBUG); + when(log.getSeverityText()).thenReturn("just testing"); + when(log.getBody()).thenReturn(Body.string("hasta la vista")); + when(logRecord.toLogRecordData()).thenReturn(log); + + bridge.setTracerProvider(testing.getOpenTelemetry().getTracerProvider()); + bridge.onEmit(Context.root(), logRecord); + + List spans = testing.getSpans(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasInstrumentationScopeInfo(scope) + .hasName("Log") + .startsAt(epochNanos) + .endsAt(epochNanos) + .hasAttributes( + Attributes.builder() + .put("attr1", "12") + .put("attr2", "42") + .put( + LogToSpanBridge.LOG_SEVERITY, + Severity.DEBUG.getSeverityNumber()) + .put(LogToSpanBridge.LOG_SEVERITY_TEXT, "just testing") + .put(LogToSpanBridge.LOG_BODY, "hasta la vista") + .build()); + } + + @Test + void event() { + long epochNanos = 123_456_789_000_000L; + when(log.getInstrumentationScopeInfo()).thenReturn(InstrumentationScopeInfo.create("test")); + when(log.getAttributes()) + .thenReturn( + Attributes.builder() + .put(SemanticAttributes.EVENT_DOMAIN, "androidApp") + .put(SemanticAttributes.EVENT_NAME, "buttonClick") + .put("attr", "value") + .build()); + when(log.getEpochNanos()).thenReturn(epochNanos); + when(log.getSeverity()).thenReturn(Severity.UNDEFINED_SEVERITY_NUMBER); + when(log.getBody()).thenReturn(Body.empty()); + when(logRecord.toLogRecordData()).thenReturn(log); + + bridge.setTracerProvider(testing.getOpenTelemetry().getTracerProvider()); + bridge.onEmit(Context.root(), logRecord); + + List spans = testing.getSpans(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasInstrumentationScopeInfo(InstrumentationScopeInfo.create("test")) + .hasName("androidApp/buttonClick") + .startsAt(epochNanos) + .endsAt(epochNanos) + .hasAttributes( + Attributes.builder() + .put(SemanticAttributes.EVENT_DOMAIN, "androidApp") + .put(SemanticAttributes.EVENT_NAME, "buttonClick") + .put("attr", "value") + .build()); + } + + @Test + void customNamedLog() { + long epochNanos = 123_456_789_000_000L; + when(log.getInstrumentationScopeInfo()).thenReturn(InstrumentationScopeInfo.create("test")); + when(log.getAttributes()) + .thenReturn( + Attributes.builder() + .put("attr1", "12") + .put("attr2", "42") + .put(LogToSpanBridge.OPERATION_NAME, "span name") + .build()); + when(log.getEpochNanos()).thenReturn(epochNanos); + when(log.getSeverity()).thenReturn(Severity.INFO); + when(log.getBody()).thenReturn(Body.string("message")); + when(logRecord.toLogRecordData()).thenReturn(log); + + bridge.setTracerProvider(testing.getOpenTelemetry().getTracerProvider()); + bridge.onEmit(Context.root(), logRecord); + + List spans = testing.getSpans(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasInstrumentationScopeInfo(InstrumentationScopeInfo.create("test")) + .hasName("span name") + .startsAt(epochNanos) + .endsAt(epochNanos) + .hasAttributes( + Attributes.builder() + .put("attr1", "12") + .put("attr2", "42") + .put(LogToSpanBridge.OPERATION_NAME, "span name") + .put( + LogToSpanBridge.LOG_SEVERITY, + Severity.INFO.getSeverityNumber()) + .put(LogToSpanBridge.LOG_BODY, "message") + .build()); + } +}