Skip to content

Add azure-functions instrumentation #8432

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 5, 2025
22 changes: 22 additions & 0 deletions dd-java-agent/instrumentation/azure-functions/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
muzzle {
pass {
group = 'com.microsoft.azure.functions'
module = 'azure-functions-java-library'

// 1.2.2 is the first stable release. The earlier versions are either betas or are unstable
versions = '[1.2.2,)'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're targeting this one and not earlier versions because it's the oldest one targeting Java 8 ?
A round major version as lower bound is pretty standard, but a specific version like this deserves a comment I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.2.2 is the earliest stable version I was able to use. Using 1.2.0 results in an error (see below). Prior to 1.2.0 all versions are beta versions. I added a comment to reflect this in code.

The following artifacts could not be resolved: com.microsoft.maven:java-8-parent:pom:8.0.0-SNAPSHOT (absent)

}
}

apply from: "$rootDir/gradle/java.gradle"

addTestSuiteForDir('latestDepTest', 'test')

dependencies {
compileOnly group: 'com.microsoft.azure.functions', name: 'azure-functions-java-library', version: '1.2.2'

testImplementation group: 'com.microsoft.azure.functions', name: 'azure-functions-java-library', version: '1.2.2'
testImplementation libs.bundles.mockito

latestDepTestImplementation group: 'com.microsoft.azure.functions', name: 'azure-functions-java-library', version: '+'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package datadog.trace.instrumentation.azurefunctions;

import com.microsoft.azure.functions.HttpRequestMessage;
import com.microsoft.azure.functions.HttpResponseMessage;
import datadog.trace.api.naming.SpanNaming;
import datadog.trace.bootstrap.instrumentation.api.AgentPropagation;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes;
import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter;
import datadog.trace.bootstrap.instrumentation.api.URIDefaultDataAdapter;
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
import datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator;

public class AzureFunctionsDecorator
extends HttpServerDecorator<
HttpRequestMessage, HttpRequestMessage, HttpResponseMessage, HttpRequestMessage> {
public static final CharSequence AZURE_FUNCTIONS = UTF8BytesString.create("azure-functions");

public static final AzureFunctionsDecorator DECORATE = new AzureFunctionsDecorator();
public static final CharSequence AZURE_FUNCTIONS_REQUEST =
UTF8BytesString.create(
SpanNaming.instance().namingSchema().cloud().operationForFaas("azure"));

@Override
protected String[] instrumentationNames() {
return new String[] {"azure-functions"};
}

@Override
protected CharSequence component() {
return AZURE_FUNCTIONS;
}

@Override
protected AgentPropagation.ContextVisitor<HttpRequestMessage> getter() {
return HttpRequestMessageExtractAdapter.GETTER;
}

@Override
protected AgentPropagation.ContextVisitor<HttpResponseMessage> responseGetter() {
return null;
}

@Override
public CharSequence spanName() {
return AZURE_FUNCTIONS_REQUEST;
}

@Override
protected String method(final HttpRequestMessage request) {
return request.getHttpMethod().name();
}

@Override
protected URIDataAdapter url(final HttpRequestMessage request) {
return new URIDefaultDataAdapter(request.getUri());
}

@Override
protected String peerHostIP(final HttpRequestMessage request) {
return null;
}

@Override
protected int peerPort(final HttpRequestMessage request) {
return 0;
}

@Override
protected CharSequence spanType() {
return InternalSpanTypes.SERVERLESS;
}

@Override
protected int status(final HttpResponseMessage response) {
return response.getStatusCode();
}

public AgentSpan afterStart(final AgentSpan span, final String functionName) {
span.setTag("aas.function.name", functionName);
span.setTag("aas.function.trigger", "Http");
return super.afterStart(span);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package datadog.trace.instrumentation.azurefunctions;

import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.declaresMethod;
import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.isAnnotatedWith;
import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activateSpan;
import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR;
import static datadog.trace.instrumentation.azurefunctions.AzureFunctionsDecorator.DECORATE;
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;

import com.google.auto.service.AutoService;
import com.microsoft.azure.functions.ExecutionContext;
import com.microsoft.azure.functions.HttpRequestMessage;
import com.microsoft.azure.functions.HttpResponseMessage;
import datadog.trace.agent.tooling.Instrumenter;
import datadog.trace.agent.tooling.InstrumenterModule;
import datadog.trace.bootstrap.instrumentation.api.AgentScope;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;

@AutoService(InstrumenterModule.class)
public class AzureFunctionsInstrumentation extends InstrumenterModule.Tracing
implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice {
public AzureFunctionsInstrumentation() {
super("azure-functions");
}

@Override
public String hierarchyMarkerType() {
/*
Due to the class-loading in the Azure Function environment we cannot assume that
"com.microsoft.azure.functions.annotation.FunctionName" will be visible (as in defined as a
resource) for any types using that annotation
*/
return null;
}

@Override
public ElementMatcher<TypeDescription> hierarchyMatcher() {
return declaresMethod(
isAnnotatedWith(named("com.microsoft.azure.functions.annotation.FunctionName")));
}

@Override
public String[] helperClassNames() {
return new String[] {
packageName + ".AzureFunctionsDecorator", packageName + ".HttpRequestMessageExtractAdapter"
};
}

public void methodAdvice(MethodTransformer transformer) {
transformer.applyAdvice(
isMethod()
.and(isPublic())
.and(takesArgument(0, named("com.microsoft.azure.functions.HttpRequestMessage")))
.and(takesArgument(1, named("com.microsoft.azure.functions.ExecutionContext"))),
AzureFunctionsInstrumentation.class.getName() + "$AzureFunctionsAdvice");
}

public static class AzureFunctionsAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static AgentScope methodEnter(
@Advice.Argument(0) final HttpRequestMessage request,
@Advice.Argument(1) final ExecutionContext context) {
final AgentSpanContext.Extracted extractedContext = DECORATE.extract(request);
final AgentSpan span = DECORATE.startSpan(request, extractedContext);
DECORATE.afterStart(span, context.getFunctionName());
DECORATE.onRequest(span, request, request, extractedContext);
HTTP_RESOURCE_DECORATOR.withRoute(
span, request.getHttpMethod().name(), request.getUri().getPath());
return activateSpan(span);
}

@Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
public static void methodExit(
@Advice.Enter final AgentScope scope,
@Advice.Return final HttpResponseMessage response,
@Advice.Thrown final Throwable throwable) {
final AgentSpan span = scope.span();
DECORATE.onError(span, throwable);
DECORATE.onResponse(span, response);
DECORATE.beforeFinish(span);
scope.close();
span.finish();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package datadog.trace.instrumentation.azurefunctions;

import com.microsoft.azure.functions.HttpRequestMessage;
import datadog.trace.bootstrap.instrumentation.api.AgentPropagation;
import datadog.trace.bootstrap.instrumentation.api.ContextVisitors;

public class HttpRequestMessageExtractAdapter
implements AgentPropagation.ContextVisitor<HttpRequestMessage> {
public static final HttpRequestMessageExtractAdapter GETTER =
new HttpRequestMessageExtractAdapter();

@Override
public void forEachKey(HttpRequestMessage carrier, AgentPropagation.KeyClassifier classifier) {
ContextVisitors.stringValuesEntrySet().forEachKey(carrier.getHeaders().entrySet(), classifier);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import static org.mockito.Mockito.mock
import static org.mockito.Mockito.when

import com.microsoft.azure.functions.ExecutionContext
import com.microsoft.azure.functions.HttpMethod
import com.microsoft.azure.functions.HttpRequestMessage
import com.microsoft.azure.functions.HttpResponseMessage
import com.microsoft.azure.functions.HttpStatus
import datadog.trace.agent.test.naming.VersionedNamingTestBase
import datadog.trace.api.DDSpanTypes
import datadog.trace.bootstrap.instrumentation.api.Tags
import okhttp3.internal.Version

abstract class AzureFunctionsTest extends VersionedNamingTestBase {

@Override
String service() {
null
}

def "test azure functions http trigger"() {
given:
HttpRequestMessage<Optional<String>> request = mock(HttpRequestMessage)
HttpResponseMessage.Builder responseBuilder = mock(HttpResponseMessage.Builder)
HttpResponseMessage response = mock(HttpResponseMessage)
ExecutionContext context = mock(ExecutionContext)

String functionName = "HttpTest"
Map<String, String> headers = ["user-agent": Version.userAgent()]
HttpMethod method = HttpMethod.GET
String responseBody = "Hello Datadog test!"
HttpStatus status = HttpStatus.OK
int statusCode = 200
URI uri = new URI("https://localhost:7071/api/HttpTest")

and:
when(request.getHeaders()).thenReturn(headers)
when(request.getHttpMethod()).thenReturn(method)
when(request.getUri()).thenReturn(uri)
when(request.createResponseBuilder(status)).thenReturn(responseBuilder)

when(responseBuilder.body(responseBody)).thenReturn(responseBuilder)
when(responseBuilder.build()).thenReturn(response)

when(response.getStatusCode()).thenReturn(statusCode)
when(response.getBody()).thenReturn(responseBody)

when(context.getFunctionName()).thenReturn(functionName)

when:
new Function().run(request, context)

then:
assertTraces(1) {
trace(1) {
span {
parent()
operationName operation()
spanType DDSpanTypes.SERVERLESS
errored false
tags {
defaultTags()
"$Tags.COMPONENT" "azure-functions"
"$Tags.SPAN_KIND" "$Tags.SPAN_KIND_SERVER"
"$Tags.HTTP_HOSTNAME" "localhost"
"$Tags.HTTP_METHOD" "GET"
"$Tags.HTTP_ROUTE" "/api/HttpTest"
"$Tags.HTTP_STATUS" 200
"$Tags.HTTP_URL" "https://localhost:7071/api/HttpTest"
"$Tags.HTTP_USER_AGENT" "${Version.userAgent()}"
"aas.function.name" "HttpTest"
"aas.function.trigger" "Http"
}
}
}
}
}
}


class AzureFunctionsV0ForkedTest extends AzureFunctionsTest {
@Override
int version() {
0
}

@Override
String operation() {
"dd-tracer-serverless-span"
}
}

class AzureFunctionsV1Test extends AzureFunctionsTest {
@Override
int version() {
1
}

@Override
String operation() {
"azure.functions.invoke"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import com.microsoft.azure.functions.ExecutionContext;
import com.microsoft.azure.functions.HttpMethod;
import com.microsoft.azure.functions.HttpRequestMessage;
import com.microsoft.azure.functions.HttpResponseMessage;
import com.microsoft.azure.functions.HttpStatus;
import com.microsoft.azure.functions.annotation.AuthorizationLevel;
import com.microsoft.azure.functions.annotation.FunctionName;
import com.microsoft.azure.functions.annotation.HttpTrigger;
import java.util.Optional;

public class Function {
@FunctionName("HttpTest")
public HttpResponseMessage run(
@HttpTrigger(
name = "req",
methods = {HttpMethod.GET},
authLevel = AuthorizationLevel.ANONYMOUS)
HttpRequestMessage<Optional<String>> request,
final ExecutionContext context) {
return request.createResponseBuilder(HttpStatus.OK).body("Hello Datadog test!").build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ public class DDSpanTypes {

public static final String MULE = "mule";
public static final String VALKEY = "valkey";

public static final String SERVERLESS = "serverless";
}
14 changes: 12 additions & 2 deletions internal-api/src/main/java/datadog/trace/api/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ public static String getHostName() {
private final int telemetryDependencyResolutionQueueSize;

private final boolean azureAppServices;
private final boolean azureFunctions;
private final String traceAgentPath;
private final List<String> traceAgentArgs;
private final String dogStatsDPath;
Expand Down Expand Up @@ -795,6 +796,9 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins

baggageMapping = configProvider.getMergedMapWithOptionalMappings(null, true, BAGGAGE_MAPPING);

azureFunctions =
getEnv("FUNCTIONS_WORKER_RUNTIME") != null && getEnv("FUNCTIONS_EXTENSION_VERSION") != null;

spanAttributeSchemaVersion = schemaVersionFromConfig();

// following two only used in v0.
Expand Down Expand Up @@ -3741,8 +3745,14 @@ private Map<String, String> getAzureAppServicesTags() {
}

private int schemaVersionFromConfig() {
String versionStr =
configProvider.getString(TRACE_SPAN_ATTRIBUTE_SCHEMA, "v" + SpanNaming.SCHEMA_MIN_VERSION);
String defaultVersion;
// use v1 so Azure Functions operation name is consistent with that of other tracers
if (azureFunctions) {
defaultVersion = "v1";
Comment on lines +3750 to +3751
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ? Why ? Can you add a comment to justify this ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be consistent with the instrumentation in other runtimes (node, python) the operation name should be azure.functions.invoke. I added a comment to reflect this in the code.

} else {
defaultVersion = "v" + SpanNaming.SCHEMA_MIN_VERSION;
}
String versionStr = configProvider.getString(TRACE_SPAN_ATTRIBUTE_SCHEMA, defaultVersion);
Matcher matcher = Pattern.compile("^v?(0|[1-9]\\d*)$").matcher(versionStr);
int parsedVersion = -1;
if (matcher.matches()) {
Expand Down
Loading
Loading