Skip to content

Commit 3186f73

Browse files
fix(azure_functions): instrument azure functions regardless of decorator order (#13592)
Refactor Azure Functions instrumentation to patch the `FunctionApp.get_functions` method instead of the individual decorators for each trigger. ## Additional Notes When the `function_name` decorator is first the function Datadog instrumentation succeeds. ``` @app.function_name(name="customname") @app.route(route="httpexample", auth_level=func.AuthLevel.ANONYMOUS) def http_example(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse("Hello Datadog!") ``` However if the `function_name` decorator is second, which is valid for an uninstrumented function, Datadog instrumentation fails with the error below. ``` @app.route(route="httpexample", auth_level=func.AuthLevel.ANONYMOUS) @app.function_name(name="customname") def http_example(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse("Hello Datadog!") ``` ``` [2025-06-04T15:47:21.117Z] Worker failed to index functions [2025-06-04T15:47:21.118Z] Result: Failure [2025-06-04T15:47:21.118Z] Exception: AttributeError: 'FunctionBuilder' object has no attribute '__name__' ``` This error happens because the decorators are patched one at a time in order, and if the `function_name` decorator is patched after the `route` decorator, then the patch method for `route` is missing information it expected from the `function_name` decorator. By patching [get_functions](https://github.com/Azure/azure-functions-python-library/blob/03dc59a10d6116e36c5255d3d3649db2d525a472/azure/functions/decorators/function_app.py#L3902) instead, we can access all functions right before they are loaded by the [Python Azure Functions Worker](https://github.com/Azure/azure-functions-python-worker/blob/9b17af96794307f4d34fc3ce8a6193aab9d7299f/azure_functions_worker/loader.py#L264) and wrap the user defined methods to create spans. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent f163540 commit 3186f73

File tree

6 files changed

+106
-83
lines changed

6 files changed

+106
-83
lines changed

ddtrace/contrib/internal/azure_functions/patch.py

Lines changed: 55 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from ddtrace.trace import Pin
1212

1313
from .utils import create_context
14-
from .utils import get_function_name
1514
from .utils import wrap_function_with_tracing
1615

1716

@@ -39,109 +38,92 @@ def patch():
3938
azure_functions._datadog_patch = True
4039

4140
Pin().onto(azure_functions.FunctionApp)
42-
_w("azure.functions", "FunctionApp.function_name", _patched_function_name)
43-
_w("azure.functions", "FunctionApp.route", _patched_route)
44-
_w("azure.functions", "FunctionApp.service_bus_queue_trigger", _patched_service_bus_trigger)
45-
_w("azure.functions", "FunctionApp.service_bus_topic_trigger", _patched_service_bus_trigger)
46-
_w("azure.functions", "FunctionApp.timer_trigger", _patched_timer_trigger)
41+
_w("azure.functions", "FunctionApp.get_functions", _patched_get_functions)
4742

4843

49-
def _patched_function_name(wrapped, instance, args, kwargs):
50-
Pin.override(instance, tags={"function_name": kwargs.get("name")})
51-
return wrapped(*args, **kwargs)
52-
53-
54-
def _patched_route(wrapped, instance, args, kwargs):
55-
trigger = "Http"
56-
trigger_arg_name = kwargs.get("trigger_arg_name", "req")
57-
44+
def _patched_get_functions(wrapped, instance, args, kwargs):
5845
pin = Pin.get_from(instance)
5946
if not pin or not pin.enabled():
6047
return wrapped(*args, **kwargs)
6148

62-
def _wrapper(func):
63-
function_name = get_function_name(pin, instance, func)
64-
65-
def context_factory(kwargs):
66-
req = kwargs.get(trigger_arg_name)
67-
return create_context("azure.functions.patched_route_request", pin, headers=req.headers)
49+
functions = wrapped(*args, **kwargs)
6850

69-
def pre_dispatch(ctx, kwargs):
70-
req = kwargs.get(trigger_arg_name)
71-
ctx.set_item("req_span", ctx.span)
72-
return ("azure.functions.request_call_modifier", (ctx, config.azure_functions, req))
51+
for function in functions:
52+
trigger = function.get_trigger()
53+
if not trigger:
54+
continue
7355

74-
def post_dispatch(ctx, res):
75-
return ("azure.functions.start_response", (ctx, config.azure_functions, res, function_name, trigger))
56+
trigger_type = trigger.get_binding_name()
57+
trigger_arg_name = trigger.name
7658

77-
wrap_function = wrap_function_with_tracing(
78-
func, context_factory, pre_dispatch=pre_dispatch, post_dispatch=post_dispatch
79-
)
59+
function_name = function.get_function_name()
60+
func = function.get_user_function()
8061

81-
return wrapped(*args, **kwargs)(wrap_function)
62+
if trigger_type == "httpTrigger":
63+
function._func = _wrap_http_trigger(pin, func, function_name, trigger_arg_name)
64+
elif trigger_type == "timerTrigger":
65+
function._func = _wrap_timer_trigger(pin, func, function_name)
66+
elif trigger_type == "serviceBusTrigger":
67+
function._func = _wrap_service_bus_trigger(pin, func, function_name)
8268

83-
return _wrapper
69+
return functions
8470

8571

86-
def _patched_service_bus_trigger(wrapped, instance, args, kwargs):
87-
trigger = "ServiceBus"
72+
def _wrap_http_trigger(pin, func, function_name, trigger_arg_name):
73+
trigger_type = "Http"
8874

89-
pin = Pin.get_from(instance)
90-
if not pin or not pin.enabled():
91-
return wrapped(*args, **kwargs)
75+
def context_factory(kwargs):
76+
req = kwargs.get(trigger_arg_name)
77+
return create_context("azure.functions.patched_route_request", pin, headers=req.headers)
9278

93-
def _wrapper(func):
94-
function_name = get_function_name(pin, instance, func)
79+
def pre_dispatch(ctx, kwargs):
80+
req = kwargs.get(trigger_arg_name)
81+
ctx.set_item("req_span", ctx.span)
82+
return ("azure.functions.request_call_modifier", (ctx, config.azure_functions, req))
9583

96-
def context_factory(kwargs):
97-
resource_name = f"{trigger} {function_name}"
98-
return create_context("azure.functions.patched_service_bus", pin, resource_name)
84+
def post_dispatch(ctx, res):
85+
return ("azure.functions.start_response", (ctx, config.azure_functions, res, function_name, trigger_type))
9986

100-
def pre_dispatch(ctx, kwargs):
101-
ctx.set_item("trigger_span", ctx.span)
102-
return (
103-
"azure.functions.trigger_call_modifier",
104-
(ctx, config.azure_functions, function_name, trigger, SpanKind.CONSUMER),
105-
)
87+
return wrap_function_with_tracing(func, context_factory, pre_dispatch=pre_dispatch, post_dispatch=post_dispatch)
10688

107-
wrap_function = wrap_function_with_tracing(func, context_factory, pre_dispatch=pre_dispatch)
10889

109-
return wrapped(*args, **kwargs)(wrap_function)
90+
def _wrap_service_bus_trigger(pin, func, function_name):
91+
trigger_type = "ServiceBus"
11092

111-
return _wrapper
93+
def context_factory(kwargs):
94+
resource_name = f"{trigger_type} {function_name}"
95+
return create_context("azure.functions.patched_service_bus", pin, resource_name)
11296

97+
def pre_dispatch(ctx, kwargs):
98+
ctx.set_item("trigger_span", ctx.span)
99+
return (
100+
"azure.functions.trigger_call_modifier",
101+
(ctx, config.azure_functions, function_name, trigger_type, SpanKind.CONSUMER),
102+
)
113103

114-
def _patched_timer_trigger(wrapped, instance, args, kwargs):
115-
trigger = "Timer"
116-
117-
pin = Pin.get_from(instance)
118-
if not pin or not pin.enabled():
119-
return wrapped(*args, **kwargs)
120-
121-
def _wrapper(func):
122-
function_name = get_function_name(pin, instance, func)
104+
return wrap_function_with_tracing(func, context_factory, pre_dispatch=pre_dispatch)
123105

124-
def context_factory(kwargs):
125-
resource_name = f"{trigger} {function_name}"
126-
return create_context("azure.functions.patched_timer", pin, resource_name)
127106

128-
def pre_dispatch(ctx, kwargs):
129-
ctx.set_item("trigger_span", ctx.span)
130-
return (
131-
"azure.functions.trigger_call_modifier",
132-
(ctx, config.azure_functions, function_name, trigger, SpanKind.INTERNAL),
133-
)
107+
def _wrap_timer_trigger(pin, func, function_name):
108+
trigger_type = "Timer"
134109

135-
wrap_function = wrap_function_with_tracing(func, context_factory, pre_dispatch=pre_dispatch)
110+
def context_factory(kwargs):
111+
resource_name = f"{trigger_type} {function_name}"
112+
return create_context("azure.functions.patched_timer", pin, resource_name)
136113

137-
return wrapped(*args, **kwargs)(wrap_function)
114+
def pre_dispatch(ctx, kwargs):
115+
ctx.set_item("trigger_span", ctx.span)
116+
return (
117+
"azure.functions.trigger_call_modifier",
118+
(ctx, config.azure_functions, function_name, trigger_type, SpanKind.INTERNAL),
119+
)
138120

139-
return _wrapper
121+
return wrap_function_with_tracing(func, context_factory, pre_dispatch=pre_dispatch)
140122

141123

142124
def unpatch():
143125
if not getattr(azure_functions, "_datadog_patch", False):
144126
return
145127
azure_functions._datadog_patch = False
146128

147-
_u(azure_functions.FunctionApp, "route")
129+
_u(azure_functions.FunctionApp, "get_functions")

ddtrace/contrib/internal/azure_functions/utils.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from ddtrace.ext import SpanTypes
77
from ddtrace.internal import core
88
from ddtrace.internal.schema import schematize_cloud_faas_operation
9-
from ddtrace.trace import Pin
109

1110

1211
def create_context(context_name, pin, resource=None, headers=None):
@@ -26,15 +25,6 @@ def create_context(context_name, pin, resource=None, headers=None):
2625
)
2726

2827

29-
def get_function_name(pin, instance, func):
30-
if pin.tags and pin.tags.get("function_name"):
31-
function_name = pin.tags.get("function_name")
32-
Pin.override(instance, tags={"function_name": ""})
33-
else:
34-
function_name = func.__name__
35-
return function_name
36-
37-
3828
def wrap_function_with_tracing(func, context_factory, pre_dispatch=None, post_dispatch=None):
3929
if inspect.iscoroutinefunction(func):
4030

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fixes:
2+
- |
3+
azure_functions: This fix resolves an issue where functions throw an error on loading when the function_name decorator follows another decorator.

tests/contrib/azure_functions/azure_function_app/function_app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ def http_get_function_name_no_decorator(req: func.HttpRequest) -> func.HttpRespo
5353
return func.HttpResponse("Hello Datadog!")
5454

5555

56+
@app.route(
57+
route="httpgetfunctionnamedecoratororder", auth_level=func.AuthLevel.ANONYMOUS, methods=[func.HttpMethod.GET]
58+
)
59+
@app.function_name(name="functionnamedecoratororder")
60+
def http_get_function_name_decorator_order(req: func.HttpRequest) -> func.HttpResponse:
61+
return func.HttpResponse("Hello Datadog!")
62+
63+
5664
@app.route(route="httpgetroot", auth_level=func.AuthLevel.ANONYMOUS, methods=[func.HttpMethod.GET])
5765
def http_get_root(req: func.HttpRequest) -> func.HttpResponse:
5866
requests.get(f"http://localhost:{os.environ['AZURE_FUNCTIONS_TEST_PORT']}/api/httpgetchild", timeout=5)

tests/contrib/azure_functions/test_azure_functions_snapshot.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ def test_http_get_function_name_no_decorator(azure_functions_client: Client) ->
107107
assert azure_functions_client.get("/api/httpgetfunctionnamenodecorator", headers=DEFAULT_HEADERS).status_code == 200
108108

109109

110+
@pytest.mark.snapshot
111+
def test_http_get_function_name_decorator_order(azure_functions_client: Client) -> None:
112+
assert (
113+
azure_functions_client.get("/api/httpgetfunctionnamedecoratororder", headers=DEFAULT_HEADERS).status_code == 200
114+
)
115+
116+
110117
@pytest.mark.parametrize(
111118
"azure_functions_client",
112119
[{}, DISTRIBUTED_TRACING_DISABLED_PARAMS],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
[[
2+
{
3+
"name": "azure.functions.invoke",
4+
"service": "test-func",
5+
"resource": "GET /api/httpgetfunctionnamedecoratororder",
6+
"trace_id": 0,
7+
"span_id": 1,
8+
"parent_id": 0,
9+
"type": "serverless",
10+
"meta": {
11+
"_dd.p.dm": "-0",
12+
"_dd.p.tid": "684055f800000000",
13+
"aas.function.name": "functionnamedecoratororder",
14+
"aas.function.trigger": "Http",
15+
"component": "azure_functions",
16+
"http.method": "GET",
17+
"http.route": "/api/httpgetfunctionnamedecoratororder",
18+
"http.status_code": "200",
19+
"http.url": "http://0.0.0.0:7071/api/httpgetfunctionnamedecoratororder",
20+
"http.useragent": "python-httpx/x.xx.x",
21+
"language": "python",
22+
"runtime-id": "e0b373739eb742d29f13fdb6cb71a98e",
23+
"span.kind": "server"
24+
},
25+
"metrics": {
26+
"_dd.top_level": 1,
27+
"_dd.tracer_kr": 1.0,
28+
"_sampling_priority_v1": 1,
29+
"process_id": 53880
30+
},
31+
"duration": 278416,
32+
"start": 1749046776355682801
33+
}]]

0 commit comments

Comments
 (0)