Skip to content

Commit 50bb0dd

Browse files
authored
Add support for endpoint_counts (#4980)
Also: * Extract event.json creation in profile exporters so it can be shared between all exporters. File exporter will be writing it so we can more easily write integration tests. * Extract web span handling in profiler
1 parent 01c3ba1 commit 50bb0dd

File tree

8 files changed

+203
-106
lines changed

8 files changed

+203
-106
lines changed

integration-tests/profiler/profiler.spec.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ function processExitPromise (proc, timeout, expectBadExit = false) {
7575
}
7676

7777
async function getLatestProfile (cwd, pattern) {
78+
const pprofGzipped = await readLatestFile(cwd, pattern)
79+
const pprofUnzipped = zlib.gunzipSync(pprofGzipped)
80+
return { profile: Profile.decode(pprofUnzipped), encoded: pprofGzipped.toString('base64') }
81+
}
82+
83+
async function readLatestFile (cwd, pattern) {
7884
const dirEntries = await fs.readdir(cwd)
7985
// Get the latest file matching the pattern
8086
const pprofEntries = dirEntries.filter(name => pattern.test(name))
@@ -83,9 +89,7 @@ async function getLatestProfile (cwd, pattern) {
8389
.map(name => ({ name, modified: fsync.statSync(path.join(cwd, name), { bigint: true }).mtimeNs }))
8490
.reduce((a, b) => a.modified > b.modified ? a : b)
8591
.name
86-
const pprofGzipped = await fs.readFile(path.join(cwd, pprofEntry))
87-
const pprofUnzipped = zlib.gunzipSync(pprofGzipped)
88-
return { profile: Profile.decode(pprofUnzipped), encoded: pprofGzipped.toString('base64') }
92+
return await fs.readFile(path.join(cwd, pprofEntry))
8993
}
9094

9195
function expectTimeout (messagePromise, allowErrors = false) {
@@ -212,6 +216,10 @@ describe('profiler', () => {
212216
await processExitPromise(proc, 30000)
213217
const procEnd = BigInt(Date.now() * 1000000)
214218

219+
// Must've counted the number of times each endpoint was hit
220+
const event = JSON.parse((await readLatestFile(cwd, /^event_.+\.json$/)).toString())
221+
assert.deepEqual(event.endpoint_counts, { 'endpoint-0': 1, 'endpoint-1': 1, 'endpoint-2': 1 })
222+
215223
const { profile, encoded } = await getLatestProfile(cwd, /^wall_.+\.pprof$/)
216224

217225
// We check the profile for following invariants:

packages/dd-trace/src/profiling/exporters/agent.js

Lines changed: 9 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
const retry = require('retry')
44
const { request: httpRequest } = require('http')
55
const { request: httpsRequest } = require('https')
6+
const { EventSerializer } = require('./event_serializer')
67

78
// TODO: avoid using dd-trace internals. Make this a separate module?
89
const docker = require('../../exporters/common/docker')
910
const FormData = require('../../exporters/common/form-data')
1011
const { storage } = require('../../../../datadog-core')
1112
const version = require('../../../../../package.json').version
12-
const os = require('os')
1313
const { urlToHttpOptions } = require('url')
1414
const perf = require('perf_hooks').performance
1515

@@ -89,83 +89,24 @@ function computeRetries (uploadTimeout) {
8989
return [tries, Math.floor(uploadTimeout)]
9090
}
9191

92-
class AgentExporter {
93-
constructor ({ url, logger, uploadTimeout, env, host, service, version, libraryInjected, activation } = {}) {
92+
class AgentExporter extends EventSerializer {
93+
constructor (config = {}) {
94+
super(config)
95+
const { url, logger, uploadTimeout } = config
9496
this._url = url
9597
this._logger = logger
9698

9799
const [backoffTries, backoffTime] = computeRetries(uploadTimeout)
98100

99101
this._backoffTime = backoffTime
100102
this._backoffTries = backoffTries
101-
this._env = env
102-
this._host = host
103-
this._service = service
104-
this._appVersion = version
105-
this._libraryInjected = !!libraryInjected
106-
this._activation = activation || 'unknown'
107103
}
108104

109-
export ({ profiles, start, end, tags }) {
105+
export (exportSpec) {
106+
const { profiles } = exportSpec
110107
const fields = []
111108

112-
function typeToFile (type) {
113-
return `${type}.pprof`
114-
}
115-
116-
const event = JSON.stringify({
117-
attachments: Object.keys(profiles).map(typeToFile),
118-
start: start.toISOString(),
119-
end: end.toISOString(),
120-
family: 'node',
121-
version: '4',
122-
tags_profiler: [
123-
'language:javascript',
124-
'runtime:nodejs',
125-
`runtime_arch:${process.arch}`,
126-
`runtime_os:${process.platform}`,
127-
`runtime_version:${process.version}`,
128-
`process_id:${process.pid}`,
129-
`profiler_version:${version}`,
130-
'format:pprof',
131-
...Object.entries(tags).map(([key, value]) => `${key}:${value}`)
132-
].join(','),
133-
info: {
134-
application: {
135-
env: this._env,
136-
service: this._service,
137-
start_time: new Date(perf.nodeTiming.nodeStart + perf.timeOrigin).toISOString(),
138-
version: this._appVersion
139-
},
140-
platform: {
141-
hostname: this._host,
142-
kernel_name: os.type(),
143-
kernel_release: os.release(),
144-
kernel_version: os.version()
145-
},
146-
profiler: {
147-
activation: this._activation,
148-
ssi: {
149-
mechanism: this._libraryInjected ? 'injected_agent' : 'none'
150-
},
151-
version
152-
},
153-
runtime: {
154-
// Using `nodejs` for consistency with the existing `runtime` tag.
155-
// Note that the event `family` property uses `node`, as that's what's
156-
// proscribed by the Intake API, but that's an internal enum and is
157-
// not customer visible.
158-
engine: 'nodejs',
159-
// strip off leading 'v'. This makes the format consistent with other
160-
// runtimes (e.g. Ruby) but not with the existing `runtime_version` tag.
161-
// We'll keep it like this as we want cross-engine consistency. We
162-
// also aren't changing the format of the existing tag as we don't want
163-
// to break it.
164-
version: process.version.substring(1)
165-
}
166-
}
167-
})
168-
109+
const event = this.getEventJSON(exportSpec)
169110
fields.push(['event', event, {
170111
filename: 'event.json',
171112
contentType: 'application/json'
@@ -181,7 +122,7 @@ class AgentExporter {
181122
return `Adding ${type} profile to agent export: ` + bytes
182123
})
183124

184-
const filename = typeToFile(type)
125+
const filename = this.typeToFile(type)
185126
fields.push([filename, buffer, {
186127
filename,
187128
contentType: 'application/octet-stream'
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const os = require('os')
2+
const perf = require('perf_hooks').performance
3+
const version = require('../../../../../package.json').version
4+
5+
class EventSerializer {
6+
constructor ({ env, host, service, version, libraryInjected, activation } = {}) {
7+
this._env = env
8+
this._host = host
9+
this._service = service
10+
this._appVersion = version
11+
this._libraryInjected = !!libraryInjected
12+
this._activation = activation || 'unknown'
13+
}
14+
15+
typeToFile (type) {
16+
return `${type}.pprof`
17+
}
18+
19+
getEventJSON ({ profiles, start, end, tags = {}, endpointCounts }) {
20+
return JSON.stringify({
21+
attachments: Object.keys(profiles).map(t => this.typeToFile(t)),
22+
start: start.toISOString(),
23+
end: end.toISOString(),
24+
family: 'node',
25+
version: '4',
26+
tags_profiler: [
27+
'language:javascript',
28+
'runtime:nodejs',
29+
`runtime_arch:${process.arch}`,
30+
`runtime_os:${process.platform}`,
31+
`runtime_version:${process.version}`,
32+
`process_id:${process.pid}`,
33+
`profiler_version:${version}`,
34+
'format:pprof',
35+
...Object.entries(tags).map(([key, value]) => `${key}:${value}`)
36+
].join(','),
37+
endpoint_counts: endpointCounts,
38+
info: {
39+
application: {
40+
env: this._env,
41+
service: this._service,
42+
start_time: new Date(perf.nodeTiming.nodeStart + perf.timeOrigin).toISOString(),
43+
version: this._appVersion
44+
},
45+
platform: {
46+
hostname: this._host,
47+
kernel_name: os.type(),
48+
kernel_release: os.release(),
49+
kernel_version: os.version()
50+
},
51+
profiler: {
52+
activation: this._activation,
53+
ssi: {
54+
mechanism: this._libraryInjected ? 'injected_agent' : 'none'
55+
},
56+
version
57+
},
58+
runtime: {
59+
// Using `nodejs` for consistency with the existing `runtime` tag.
60+
// Note that the event `family` property uses `node`, as that's what's
61+
// proscribed by the Intake API, but that's an internal enum and is
62+
// not customer visible.
63+
engine: 'nodejs',
64+
// strip off leading 'v'. This makes the format consistent with other
65+
// runtimes (e.g. Ruby) but not with the existing `runtime_version` tag.
66+
// We'll keep it like this as we want cross-engine consistency. We
67+
// also aren't changing the format of the existing tag as we don't want
68+
// to break it.
69+
version: process.version.substring(1)
70+
}
71+
}
72+
})
73+
}
74+
}
75+
76+
module.exports = { EventSerializer }

packages/dd-trace/src/profiling/exporters/file.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,29 @@ const fs = require('fs')
44
const { promisify } = require('util')
55
const { threadId } = require('worker_threads')
66
const writeFile = promisify(fs.writeFile)
7+
const { EventSerializer } = require('./event_serializer')
78

89
function formatDateTime (t) {
910
const pad = (n) => String(n).padStart(2, '0')
1011
return `${t.getUTCFullYear()}${pad(t.getUTCMonth() + 1)}${pad(t.getUTCDate())}` +
1112
`T${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}${pad(t.getUTCSeconds())}Z`
1213
}
1314

14-
class FileExporter {
15-
constructor ({ pprofPrefix } = {}) {
15+
class FileExporter extends EventSerializer {
16+
constructor (config = {}) {
17+
super(config)
18+
const { pprofPrefix } = config
1619
this._pprofPrefix = pprofPrefix || ''
1720
}
1821

19-
export ({ profiles, end }) {
22+
export (exportSpec) {
23+
const { profiles, end } = exportSpec
2024
const types = Object.keys(profiles)
2125
const dateStr = formatDateTime(end)
2226
const tasks = types.map(type => {
2327
return writeFile(`${this._pprofPrefix}${type}_worker_${threadId}_${dateStr}.pprof`, profiles[type])
2428
})
25-
29+
tasks.push(writeFile(`event_worker_${threadId}_${dateStr}.json`, this.getEventJSON(exportSpec)))
2630
return Promise.all(tasks)
2731
}
2832
}

packages/dd-trace/src/profiling/profiler.js

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ const { EventEmitter } = require('events')
44
const { Config } = require('./config')
55
const { snapshotKinds } = require('./constants')
66
const { threadNamePrefix } = require('./profilers/shared')
7+
const { isWebServerSpan, endpointNameFromTags, getStartedSpans } = require('./webspan-utils')
78
const dc = require('dc-polyfill')
89

910
const profileSubmittedChannel = dc.channel('datadog:profiling:profile-submitted')
11+
const spanFinishedChannel = dc.channel('dd-trace:span:finish')
1012

1113
function maybeSourceMap (sourceMap, SourceMapper, debug) {
1214
if (!sourceMap) return
@@ -21,6 +23,20 @@ function logError (logger, err) {
2123
}
2224
}
2325

26+
function findWebSpan (startedSpans, spanId) {
27+
for (let i = startedSpans.length; --i >= 0;) {
28+
const ispan = startedSpans[i]
29+
const context = ispan.context()
30+
if (context._spanId === spanId) {
31+
if (isWebServerSpan(context._tags)) {
32+
return true
33+
}
34+
spanId = context._parentId
35+
}
36+
}
37+
return false
38+
}
39+
2440
class Profiler extends EventEmitter {
2541
constructor () {
2642
super()
@@ -30,6 +46,7 @@ class Profiler extends EventEmitter {
3046
this._timer = undefined
3147
this._lastStart = undefined
3248
this._timeoutInterval = undefined
49+
this.endpointCounts = new Map()
3350
}
3451

3552
start (options) {
@@ -82,6 +99,11 @@ class Profiler extends EventEmitter {
8299
this._logger.debug(`Started ${profiler.type} profiler in ${threadNamePrefix} thread`)
83100
}
84101

102+
if (config.endpointCollectionEnabled) {
103+
this._spanFinishListener = this._onSpanFinish.bind(this)
104+
spanFinishedChannel.subscribe(this._spanFinishListener)
105+
}
106+
85107
this._capture(this._timeoutInterval, start)
86108
return true
87109
} catch (e) {
@@ -117,6 +139,11 @@ class Profiler extends EventEmitter {
117139

118140
this._enabled = false
119141

142+
if (this._spanFinishListener !== undefined) {
143+
spanFinishedChannel.unsubscribe(this._spanFinishListener)
144+
this._spanFinishListener = undefined
145+
}
146+
120147
for (const profiler of this._config.profilers) {
121148
profiler.stop()
122149
this._logger.debug(`Stopped ${profiler.type} profiler in ${threadNamePrefix} thread`)
@@ -137,6 +164,26 @@ class Profiler extends EventEmitter {
137164
}
138165
}
139166

167+
_onSpanFinish (span) {
168+
const context = span.context()
169+
const tags = context._tags
170+
if (!isWebServerSpan(tags)) return
171+
172+
const endpointName = endpointNameFromTags(tags)
173+
if (!endpointName) return
174+
175+
// Make sure this is the outermost web span, just in case so we don't overcount
176+
if (findWebSpan(getStartedSpans(context), context._parentId)) return
177+
178+
let counter = this.endpointCounts.get(endpointName)
179+
if (counter === undefined) {
180+
counter = { count: 1 }
181+
this.endpointCounts.set(endpointName, counter)
182+
} else {
183+
counter.count++
184+
}
185+
}
186+
140187
async _collect (snapshotKind, restart = true) {
141188
if (!this._enabled) return
142189

@@ -194,18 +241,23 @@ class Profiler extends EventEmitter {
194241

195242
_submit (profiles, start, end, snapshotKind) {
196243
const { tags } = this._config
197-
const tasks = []
198244

199-
tags.snapshot = snapshotKind
200-
for (const exporter of this._config.exporters) {
201-
const task = exporter.export({ profiles, start, end, tags })
202-
.catch(err => {
203-
if (this._logger) {
204-
this._logger.warn(err)
205-
}
206-
})
207-
tasks.push(task)
245+
// Flatten endpoint counts
246+
const endpointCounts = {}
247+
for (const [endpoint, { count }] of this.endpointCounts) {
248+
endpointCounts[endpoint] = count
208249
}
250+
this.endpointCounts.clear()
251+
252+
tags.snapshot = snapshotKind
253+
const exportSpec = { profiles, start, end, tags, endpointCounts }
254+
const tasks = this._config.exporters.map(exporter =>
255+
exporter.export(exportSpec).catch(err => {
256+
if (this._logger) {
257+
this._logger.warn(err)
258+
}
259+
})
260+
)
209261

210262
return Promise.all(tasks)
211263
}

0 commit comments

Comments
 (0)