Skip to content

Commit 9865e1c

Browse files
feat: Azure Function HTTP streaming support (newrelic#3070)
1 parent 7dc9aad commit 9865e1c

File tree

4 files changed

+80
-4
lines changed

4 files changed

+80
-4
lines changed

lib/instrumentation/@azure/functions.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const defaultLogger = require('../../logger').child({ component: 'azure-function
99
const urltils = require('../../util/urltils')
1010
const headerProcessing = require('../../header-processing')
1111
const synthetics = require('../../synthetics')
12+
const { Transform } = require('node:stream')
1213

1314
const backgroundRecorder = require('../../metrics/recorders/other.js')
1415
const recordWeb = require('../../metrics/recorders/http')
@@ -134,24 +135,29 @@ function wrapAzureHttpMethods(shim, appMethod) {
134135

135136
const newContext = ctx.enterSegment({ segment })
136137
const boundHandler = tracer.bindFunction(handler, newContext)
137-
138138
const result = await boundHandler(...args)
139+
139140
// Responses should have a shape as described at:
140141
// https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=javascript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response
141-
if (result.status) {
142+
if (result?.status) {
142143
tx.trace.attributes.addAttribute(
143144
DESTS.TRANS_COMMON,
144145
'http.statusCode',
145146
result.status
146147
)
147148
}
148-
149149
if (coldStart === true) {
150150
tx.trace.attributes.addAttribute(DESTS.TRANS_COMMON, 'faas.coldStart', true)
151151
coldStart = false
152152
}
153+
if (result?.body instanceof Transform) {
154+
result.body.on('close', () => {
155+
tx.end()
156+
})
157+
} else {
158+
tx.end()
159+
}
153160

154-
tx.end()
155161
return result
156162
})
157163

lib/instrumentation/core/http.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ function instrumentedFinish(segment, transaction) {
167167

168168
// Naming must happen before the segment and transaction are ended,
169169
// because metrics recording depends on naming's side effects.
170+
170171
transaction.finalizeNameFromUri(transaction.parsedUrl, this.statusCode)
171172

172173
if (this) {
@@ -444,6 +445,11 @@ module.exports = function initialize(agent, http, moduleName) {
444445
return false
445446
}
446447

448+
if (process.env.FUNCTIONS_WORKER_RUNTIME) {
449+
logger.debug('In azure functions environment, disabling core http instrumentation in favor of @azure/functions')
450+
return false
451+
}
452+
447453
const IS_HTTPS = moduleName === 'https'
448454

449455
// FIXME: will this ever not be called?

test/unit/instrumentation/http/http.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ test('built-in http module instrumentation', async (t) => {
4545

4646
t.afterEach((ctx) => {
4747
helper.unloadAgent(ctx.nr.agent)
48+
process.env.FUNCTIONS_WORKER_RUNTIME = ''
4849
})
4950

5051
await t.test('when passed no module', (t) => {
@@ -56,6 +57,23 @@ test('built-in http module instrumentation', async (t) => {
5657
const { agent, initialize } = t.nr
5758
assert.doesNotThrow(() => initialize(agent, {}, 'http', new Shim(agent, 'http')))
5859
})
60+
61+
await t.test('should not instrument if azure functions environment detected', (t) => {
62+
const { agent, initialize } = t.nr
63+
process.env.FUNCTIONS_WORKER_RUNTIME = 'node'
64+
const http = {
65+
request: function request(options) {
66+
const requested = new EventEmitter()
67+
requested.path = '/TEST'
68+
if (options.path) {
69+
requested.path = options.path
70+
}
71+
72+
return requested
73+
}
74+
}
75+
assert.equal(initialize(agent, http, 'http', new Shim(agent, 'http')), false)
76+
})
5977
})
6078

6179
await t.test('after loading', async (t) => {

test/versioned/azure-functions/http.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
const test = require('node:test')
1111
const assert = require('node:assert')
1212
const { once } = require('node:events')
13+
const { Transform, Readable } = require('node:stream')
1314

1415
const helper = require('../../lib/agent_helper.js')
1516
const { removeMatchedModules } = require('../../lib/cache-buster.js')
@@ -352,3 +353,48 @@ test('uses port provided in url', async (t) => {
352353
assert.ok(tx)
353354
assert.equal(tx.port, '8080')
354355
})
356+
357+
test('ends transaction on stream close', async (t) => {
358+
bootstrapModule({ t })
359+
const { agent, initialize, mockApi, shim } = t.nr
360+
initialize(agent, mockApi, MODULE_NAME, shim)
361+
362+
const handler = async function () {
363+
const response = new AzureFunctionHttpResponse()
364+
const stream = new Readable({
365+
read() {
366+
this.push('streamed data')
367+
this.push(null) // End the stream
368+
}
369+
})
370+
response.body = stream.pipe(new Transform({
371+
transform(chunk, encoding, callback) {
372+
this.push(chunk.toString())
373+
callback()
374+
}
375+
}))
376+
response.status = 200
377+
return response
378+
}
379+
const options = { handler }
380+
381+
const txFinished = once(agent, 'transactionFinished')
382+
mockApi.app.get('a-test', options)
383+
const response = await mockApi.httpRequest('get')
384+
385+
response.body.on('data', (data) => {
386+
assert.equal(data.toString(), 'streamed data')
387+
})
388+
await new Promise((resolve, reject) => {
389+
response.body.on('close', async () => {
390+
try {
391+
const [tx] = await txFinished
392+
assert.ok(tx)
393+
assert.equal(tx.baseSegment.name, 'WebTransaction/AzureFunction/test-func')
394+
resolve()
395+
} catch (err) {
396+
reject(err)
397+
}
398+
})
399+
})
400+
})

0 commit comments

Comments
 (0)