-
Notifications
You must be signed in to change notification settings - Fork 406
feat: add durable-functions integration #7535
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
Changes from all commits
7b5d236
567e285
2e426b9
5a4402e
b187a80
d87a3d3
75be9e8
34af747
3f0dfe4
7dae872
4532834
f251956
055c331
36a1d9b
a6654cd
38dad4c
e24b38b
33246ee
371aa84
02c07c4
cb7654b
06f902d
e547255
e7def9d
101cd10
3b44957
57ca81f
50a08fd
a2a60ab
32d5a54
3999088
4692386
18e3707
69d1e32
bb41734
c8d8f6a
d4c7201
6c6a3ba
19a087c
3ff56ca
72f249c
a974bac
c39fcc2
de96275
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| '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)) | ||
| } else { | ||
| // if an object, access the handler then trace it | ||
| shimmer.wrap(arg, 'handler', handler => entityHandler(handler, entityName)) | ||
| } | ||
|
|
||
| return method.apply(this, arguments) | ||
| } | ||
| } | ||
|
|
||
| function entityHandler (handler, entityName) { | ||
| return function () { | ||
| if (!azureDurableFunctionsChannel.hasSubscribers) return handler.apply(this, arguments) | ||
|
|
||
| const entityContext = arguments[0] | ||
|
ojproductions marked this conversation as resolved.
|
||
| 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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The activity wrapper chooses Useful? React with 👍 / 👎. |
||
|
|
||
| return function () { | ||
| if (!azureDurableFunctionsChannel.hasSubscribers) return handler.apply(this, arguments) | ||
|
|
||
| // 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) | ||
| } | ||
| } | ||
| 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: { | ||
|
jcstorms1 marked this conversation as resolved.
|
||
| component: 'azure-functions', | ||
|
jcstorms1 marked this conversation as resolved.
|
||
| '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) | ||
| } | ||
|
|
||
| asyncStart (ctx) { | ||
| 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,145 @@ | ||
| 'use strict' | ||
|
|
||
| const assert = require('node:assert/strict') | ||
|
|
||
| const { spawn } = require('child_process') | ||
| const { describe, it } = require('mocha') | ||
| const { | ||
| FakeAgent, | ||
| hookFile, | ||
| sandboxCwd, | ||
| useSandbox, | ||
| curlAndAssertMessage, | ||
| assertObjectContains, | ||
| } = 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 () => { | ||
|
jcstorms1 marked this conversation as resolved.
|
||
| 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) | ||
| assertObjectContains(maybeHolaActivity[0], { | ||
| name: 'azure.functions.invoke', | ||
| resource: 'Activity hola', | ||
| meta: { | ||
| 'aas.function.trigger': 'Activity', | ||
| 'aas.function.name': 'hola', | ||
| }, | ||
| }) | ||
|
|
||
| assert.strictEqual(maybeAddNEntity.length, 1) | ||
| assertObjectContains(maybeAddNEntity[0], { | ||
| name: 'azure.functions.invoke', | ||
| resource: 'Entity counter add_n', | ||
| meta: { | ||
| 'aas.function.trigger': 'Entity', | ||
| 'aas.function.name': 'counter', | ||
| 'aas.function.operation': 'add_n', | ||
| }, | ||
| }) | ||
|
|
||
| assert.strictEqual(maybeGetCountEntity.length, 1) | ||
| assertObjectContains(maybeGetCountEntity[0], { | ||
| name: 'azure.functions.invoke', | ||
| resource: 'Entity counter get_count', | ||
| meta: { | ||
| 'aas.function.trigger': 'Entity', | ||
| 'aas.function.name': 'counter', | ||
| 'aas.function.operation': 'get_count', | ||
| }, | ||
| }) | ||
| }) | ||
| }).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()) | ||
| }) | ||
| }) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.