Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7b5d236
azure durable functions
ojproductions Feb 2, 2026
567e285
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 3, 2026
2e426b9
debug logs
ojproductions Feb 3, 2026
5a4402e
debug logs
ojproductions Feb 3, 2026
b187a80
debug logs
ojproductions Feb 3, 2026
d87a3d3
debug logs
ojproductions Feb 4, 2026
75be9e8
add durable-functions hook
ojproductions Feb 4, 2026
34af747
add consistent plugin naming
ojproductions Feb 4, 2026
3f0dfe4
update supported configs
ojproductions Feb 4, 2026
7dae872
add spans
ojproductions Feb 4, 2026
4532834
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 9, 2026
f251956
add entity instrumentation
ojproductions Feb 9, 2026
055c331
remove debug logs
ojproductions Feb 9, 2026
36a1d9b
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 13, 2026
a6654cd
tests
ojproductions Feb 13, 2026
38dad4c
tests pt2
ojproductions Feb 13, 2026
e24b38b
add df plugin interface and fix esm issues
ojproductions Feb 15, 2026
33246ee
add azure-durable-function to api.md
ojproductions Feb 16, 2026
371aa84
Merge remote-tracking branch 'origin/master' into onzia/azure-durable…
ojproductions Feb 16, 2026
02c07c4
add ci
ojproductions Feb 17, 2026
cb7654b
remove azurite proc
ojproductions Feb 18, 2026
06f902d
add envs to ci
ojproductions Feb 18, 2026
e547255
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 18, 2026
e7def9d
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 23, 2026
101cd10
remove azurite dep and run yarn
ojproductions Feb 23, 2026
3b44957
Merge branch 'master' into onzia/azure-durable-functions
ojproductions Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,25 @@ jobs:
with:
dd_api_key: ${{ secrets.DD_API_KEY }}

azure-durable-functions:
runs-on: ubuntu-latest
services:
azurite:
image: mcr.microsoft.com/azure-storage/azurite:3.34.0
ports:
- "127.0.0.1:10000:10000"
- "127.0.0.1:10001:10001"
- "127.0.0.1:10002:10002"
env:
PLUGINS: azure-durable-functions
SERVICES: azurite

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/plugins/test
with:
dd_api_key: ${{ secrets.DD_API_KEY }}

google-cloud-pubsub:
runs-on: ubuntu-latest
services:
Expand Down
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tracer.use('pg', {
<h5 id="azure-event-hubs"></h5>
<h5 id="azure-functions"></h5>
<h5 id="azure-service-bus"></h5>
<h5 id="azure-durable-functions"></h5>
<h5 id="bullmq"></h5>
<h5 id="bunyan"></h5>
<h5 id="cassandra-driver"></h5>
Expand Down Expand Up @@ -112,6 +113,7 @@ tracer.use('pg', {
* [azure-event-hubs](./interfaces/export_.plugins.azure_event_hubs.html)
* [azure-functions](./interfaces/export_.plugins.azure_functions.html)
* [azure-service-bus](./interfaces/export_.plugins.azure_service_bus.html)
* [azure-durable-functions](./interfaces/export_.plugins.azure_durable_functions.html)
* [bullmq](./interfaces/export_.plugins.bullmq.html)
* [bunyan](./interfaces/export_.plugins.bunyan.html)
* [cassandra-driver](./interfaces/export_.plugins.cassandra_driver.html)
Expand Down
7 changes: 7 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ interface Plugins {
"azure-event-hubs": tracer.plugins.azure_event_hubs;
"azure-functions": tracer.plugins.azure_functions;
"azure-service-bus": tracer.plugins.azure_service_bus;
"azure-durable-functions": tracer.plugins.azure_durable_functions
"bullmq": tracer.plugins.bullmq;
"bunyan": tracer.plugins.bunyan;
"cassandra-driver": tracer.plugins.cassandra_driver;
Expand Down Expand Up @@ -1918,6 +1919,12 @@ declare namespace tracer {
*/
interface azure_service_bus extends Integration {}

/**
* This plugin automatically instruments the
* durable-functions module
*/
interface azure_durable_functions extends Integration {}

/**
* This plugin patches the [bunyan](https://github.com/trentm/node-bunyan)
* to automatically inject trace identifiers in log records when the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict'

const dc = require('dc-polyfill')
const shimmer = require('../../datadog-shimmer')

const {
addHook,
} = require('./helpers/instrument')

/**
* @type {import('diagnostics_channel').TracingChannel}
*/
const azureDurableFunctionsChannel = dc.tracingChannel('datadog:azure:durable-functions:invoke')

addHook({ name: 'durable-functions', versions: ['>=3'], patchDefault: false }, (df) => {
const { app } = df

shimmer.wrap(app, 'entity', entityWrapper)
shimmer.wrap(app, 'activity', activityHandler)

return df
})

function entityWrapper (method) {
return function (entityName, arg) {
// because this method is overloaded, the second argument can either be an object
// with the handler or the handler itself, so first we figure which type it is

if (typeof arg === 'function') {
// if a function, this is the handler we want to wrap and trace
arguments[1] = shimmer.wrapFunction(arg, handler => entityHandler(handler, entityName, method.name))
} else {
// if an object, access the handler then trace it
shimmer.wrap(arg, 'handler', handler => entityHandler(handler, entityName, method.name))
}

return method.apply(this, arguments)
}
}

function entityHandler (handler, entityName, methodName) {
return function () {
const entityContext = arguments[0]
return azureDurableFunctionsChannel.traceSync(
handler,
{ trigger: 'Entity', functionName: entityName, operationName: entityContext?.df?.operationName },
this, ...arguments)
}
}

function activityHandler (method) {
return function (activityName, activityOptions) {
shimmer.wrap(activityOptions, 'handler', handler => {
const isAsync =
handler && handler.constructor && handler.constructor.name === 'AsyncFunction'

Choose a reason for hiding this comment

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

P1 Badge Handle promise-returning activity handlers as async

The activity wrapper chooses traceSync unless handler.constructor.name === 'AsyncFunction', which misses valid handlers that return a Promise but are not declared with async (for example transpiled async functions or plain functions returning Promise). In that case the span is finished on end before the Promise settles, so latency and rejection errors are not captured for the activity execution.

Useful? React with 👍 / 👎.


return function () {
// use tracePromise if this is an async handler. otherwise, use traceSync
return isAsync
? azureDurableFunctionsChannel.tracePromise(
handler,
{ trigger: 'Activity', functionName: activityName },
this, ...arguments)
: azureDurableFunctionsChannel.traceSync(
handler,
{ trigger: 'Activity', functionName: activityName },
this, ...arguments)
}
})
return method.apply(this, arguments)
}
}
5 changes: 5 additions & 0 deletions packages/datadog-instrumentations/src/azure-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ const {
addHook,
} = require('./helpers/instrument')

let alreadyPatched = false
Copy link
Contributor

Choose a reason for hiding this comment

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

Does adding this functionality to azure-functions.js affect the durable integration? Curious why this was added. Is it the result of patching standard and esm modules? It might be worth bringing this to the node guild for more innput.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm looking into it. Ideally patchDefault should prevent the patching of the defaultExport.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was not able to replicate it by running the client.spec.js in the http-test folder. I might need some more context on how the double patching was identified.

Does removing that conditional make the tests fail?

Copy link
Author

Choose a reason for hiding this comment

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

Pablo and I talked offline but I'm answering here for record. This is happening in the durable-functions tests not azure functions. Using esm imports produces an extra span. This is being addressed in #7601


const azureFunctionsChannel = dc.tracingChannel('datadog:azure:functions:invoke')

addHook({ name: '@azure/functions', versions: ['>=4'], patchDefault: false }, (azureFunction) => {
const { app } = azureFunction

if (alreadyPatched) return azureFunction
Copy link
Author

@ojproductions ojproductions Feb 18, 2026

Choose a reason for hiding this comment

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

for some reason, when using esm imports, azure functions gets patched twice, resulting in extra spans. hence I added this. Why does this happen?

Copy link
Contributor

Choose a reason for hiding this comment

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

For ESM we always patch both the default export and the outer export.

In some cases, the default export and the outer export end up triggering a patch on the same object or function. For example, we observed this behavior when a patch was applied to the prototype of an object that was shared by both the default export and the outer export.

Copy link
Author

Choose a reason for hiding this comment

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

does the patchDefault: false option in addhook not affect this behavior at all?

Copy link
Contributor

Choose a reason for hiding this comment

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

I solved this in service bus by using a weakmap like this. I'm not sure if that's the approach we want to take here though.

Copy link
Author

Choose a reason for hiding this comment

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

Me and @pabloerhard talked and it seems this may be due to how durable-functions is importing @azure/functions under the hood using require. He's working on a fix in #7601

alreadyPatched = true

// Http triggers
shimmer.wrap(app, 'deleteRequest', wrapHandler)
shimmer.wrap(app, 'http', wrapHandler)
Expand Down
1 change: 1 addition & 0 deletions packages/datadog-instrumentations/src/helpers/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
'@aws-sdk/smithy-client': () => require('../aws-sdk'),
'@azure/event-hubs': () => require('../azure-event-hubs'),
'@azure/functions': () => require('../azure-functions'),
'durable-functions': () => require('../azure-durable-functions'),
'@azure/service-bus': () => require('../azure-service-bus'),
'@cucumber/cucumber': () => require('../cucumber'),
'@playwright/test': () => require('../playwright'),
Expand Down
49 changes: 49 additions & 0 deletions packages/datadog-plugin-azure-durable-functions/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict'

const TracingPlugin = require('../../dd-trace/src/plugins/tracing')

class AzureDurableFunctionsPlugin extends TracingPlugin {
static get id () { return 'azure-durable-functions' }
static get operation () { return 'invoke' }
static get prefix () { return 'tracing:datadog:azure:durable-functions:invoke' }
static get type () { return 'serverless' }
static get kind () { return 'server' }

bindStart (ctx) {
const span = this.startSpan(this.operationName(), {
kind: 'internal',
type: 'serverless',

meta: {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if you have this in a doc or not, but I'd double check we have all the metadata we need and compare it with a normal function. I imagine a lot of the Azure tags won't be necessary for durable functions, but we should make sure there is as much in common as possible.

component: 'azure-functions',

Choose a reason for hiding this comment

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

P2 Badge Use the durable-functions component tag

The plugin starts spans for azure-durable-functions but explicitly sets meta.component to azure-functions, overriding the plugin component and labeling these spans as the wrong integration. This causes durable-function traces to be attributed to the Azure Functions integration instead of their own component.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, are we considering these to be the same component as standard azure functions or should we make this have a more specific component tag for durable functions?

'aas.function.name': ctx.functionName,
'aas.function.trigger': ctx.trigger,
'resource.name': `${ctx.trigger} ${ctx.functionName}`,
},
}, ctx)

// in the case of entity functions, operationName should be available
if (ctx.operationName) {
span.setTag('aas.function.operation', ctx.operationName)
span.setTag('resource.name', `${ctx.trigger} ${ctx.functionName} ${ctx.operationName}`
)
}

ctx.span = span
return ctx.currentStore
}

end (ctx) {
// We only want to run finish here if this is a synchronous operation
// Only synchronous operations would have `result` or `error` on `end`
// So we skip operations that dont
if (!ctx.hasOwnProperty('result') && !ctx.hasOwnProperty('error')) return
super.finish(ctx)
}

asyncEnd (ctx) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I know it seems strange, but it is better to make this asyncStart for a more accurate trace. My initial intuition was to use these to capture the end of traceAsync, but I was corrected as well.

asyncStart and asyncEnd are essentially occurring right after each other without anything happening in-between. In fact, asyncEnd isn't necessary in the majority of cases. The edge case would be if we need information from a call back function, which we do not patch in this integration.

start
code patched
end
asyncStart
if there was a callback it would execute here.
asyncEnd

super.finish(ctx)
}
}

module.exports = AzureDurableFunctionsPlugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.0.0, 4.28.0)"
},
"extensions": {
"durableTask": {
"storageProvider": {
"type": "AzureStorage"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
"AzureWebJobsFeatureFlags": "EnableWorkerIndexing",
"AzureWebJobsStorage": "UseDevelopmentStorage=true"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "azure-durable-functions-tests",
"version": "1.0.0",
"description": "",
"main": "./server.mjs",
"scripts": {
"start": "func start"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use strict'

const assert = require('node:assert/strict')

const { spawn } = require('child_process')
const { describe, it } = require('mocha')
const {
FakeAgent,
hookFile,
sandboxCwd,
useSandbox,
curlAndAssertMessage,
} = require('../../../../integration-tests/helpers')
const { withVersions } = require('../../../dd-trace/test/setup/mocha')

describe('esm', () => {
let agent
let proc

withVersions('azure-durable-functions', 'durable-functions', version => {
useSandbox([
`durable-functions@${version}`,
'@azure/functions',
'azure-functions-core-tools@4',
],
false,
['./packages/datadog-plugin-azure-durable-functions/test/integration-test/*',
'./packages/datadog-plugin-azure-durable-functions/test/fixtures/*',
])

beforeEach(async () => {
agent = await new FakeAgent().start()
})

afterEach(async () => {
// after each test, kill process and wait for exit before continuing
if (proc) {
proc.kill('SIGINT')
await new Promise(resolve => proc.on('exit', resolve))
}
await agent.stop()
})

it('is instrumented', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need a separate test for entity and non-entity functions or is this sufficient? I believe there is separate logic in the case of an entity function and we should probably capture that.

proc = await spawnPluginIntegrationTestProc(agent.port)
return await curlAndAssertMessage(agent, 'http://127.0.0.1:7071/api/httptest', ({ headers, payload }) => {
assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`)
assert.ok(Array.isArray(payload))

// should expect spans for http.request, activity.hola, entity.counter.add_n, entity.counter.get_count
assert.strictEqual(payload.length, 4)

for (const maybeArray of payload) {
assert.ok(Array.isArray(maybeArray))
}

const [maybeHttpSpan, maybeHolaActivity, maybeAddNEntity, maybeGetCountEntity] = payload

assert.strictEqual(maybeHttpSpan.length, 2)
assert.strictEqual(maybeHttpSpan[0].resource, 'GET /api/httptest')

assert.strictEqual(maybeHolaActivity.length, 1)
assert.strictEqual(maybeHolaActivity[0].resource, 'Activity hola')
assert.strictEqual(maybeHolaActivity[0].name, 'azure.durable-functions.invoke')

assert.strictEqual(maybeAddNEntity.length, 1)
assert.strictEqual(maybeAddNEntity[0].resource, 'Entity Counter add_n')
assert.strictEqual(maybeAddNEntity[0].name, 'azure.durable-functions.invoke')

assert.strictEqual(maybeGetCountEntity.length, 1)
assert.strictEqual(maybeGetCountEntity[0].resource, 'Entity Counter get_count')
assert.strictEqual(maybeGetCountEntity[0].name, 'azure.durable-functions.invoke')
})
}).timeout(60_000)
})
})

/**
* - spawns process for azure func start commands
* - connects to azurite (running in container)
* then runs the durable function locally
*/
async function spawnPluginIntegrationTestProc (agentPort) {
const cwd = sandboxCwd()
const env = {
NODE_OPTIONS: `--loader=${hookFile}`,
DD_TRACE_AGENT_PORT: agentPort,
DD_TRACE_DISABLED_PLUGINS: 'amqplib,amqp10,rhea,net',
PATH: `${cwd}/node_modules/azure-functions-core-tools/bin:${process.env.PATH}`,
}

const options = { cwd, env }

const proc = await spawnProc('func', ['start'], options)
return proc
}

function spawnProc (command, args, options = {}) {
const proc = spawn(command, args, { ...options, stdio: 'pipe' })
return new Promise((resolve, reject) => {
proc
.on('error', reject)
.on('exit', code => {
if (code !== 0) {
reject(new Error(`Process exited with status code ${code}.`))
}
resolve()
})

proc.stdout.on('data', data => {
// eslint-disable-next-line no-console
if (!options.silent) console.log(data.toString())

if (data.toString().includes('Host lock lease acquired by instance')) {
resolve(proc)
}
})

proc.stderr.on('data', data => {
// eslint-disable-next-line no-console
if (!options.silent) console.error(data.toString())
})
})
}
Loading
Loading