Skip to content

Commit 5b591d0

Browse files
committed
test(telemetry): add tests
1 parent bd80cc9 commit 5b591d0

File tree

6 files changed

+209
-1
lines changed

6 files changed

+209
-1
lines changed

lib/telemetry/jest.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const base = require('../../jest.config.base')
2+
3+
module.exports = {
4+
...base.config
5+
}

lib/telemetry/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "",
55
"main": "lib",
66
"scripts": {
7-
"test": "echo \"Error: no test specified\" && exit 1"
7+
"test": "cd ../../ ; npx jest --silent --projects lib/telemetry"
88
},
99
"keywords": [],
1010
"author": "FT.com Platforms Team <platforms-team.customer-products@ft.com>",
@@ -27,5 +27,8 @@
2727
},
2828
"dependencies": {
2929
"@dotcom-tool-kit/logger": "^5.0.0"
30+
},
31+
"devDependencies": {
32+
"@jest/globals": "^29.7.0"
3033
}
3134
}

lib/telemetry/test/index.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { createServer, type Server } from 'node:http'
2+
import type { AddressInfo } from 'node:net'
3+
4+
import { expect, test } from '@jest/globals'
5+
import winston, { type Logger } from 'winston'
6+
7+
import { TelemetryProcess, TelemetryRecorder, type TelemetryEvent } from '../src'
8+
import { ChildProcess, fork } from 'node:child_process'
9+
10+
const logger = winston as unknown as Logger
11+
12+
function createAndRegisterMockServer() {
13+
const server = createServer()
14+
server.listen()
15+
process.env.TOOL_KIT_TELEMETRY_ENDPOINT = `http://localhost:${(server.address() as AddressInfo).port}`
16+
return server
17+
}
18+
19+
async function listenForTelemetry(mockServer: Server, metricCount: number, responseTimeout?: number) {
20+
let requestListener
21+
const metrics = await new Promise<TelemetryEvent[][]>((resolve) => {
22+
const metrics: TelemetryEvent[][] = []
23+
requestListener = (req, res) => {
24+
const bodyBuffer: Uint8Array[] = []
25+
req
26+
.on('data', (chunk) => {
27+
bodyBuffer.push(chunk)
28+
})
29+
.on('end', () => {
30+
const body = Buffer.concat(bodyBuffer).toString()
31+
const parsed = JSON.parse(body)
32+
metrics.push(parsed)
33+
if (metrics.flat().length >= metricCount) {
34+
resolve(metrics)
35+
}
36+
})
37+
38+
const sendResponse = () => {
39+
res.writeHead(200, { 'Content-Type': 'text/plain' })
40+
res.end('metric received\n')
41+
}
42+
responseTimeout ? setTimeout(sendResponse, responseTimeout) : sendResponse()
43+
}
44+
mockServer.on('request', requestListener)
45+
})
46+
mockServer.removeListener('request', requestListener)
47+
48+
expect(metrics.flat()).toHaveLength(metricCount)
49+
return metrics
50+
}
51+
52+
describe('attribute handling', () => {
53+
const metricsMock = jest.fn()
54+
const mockProcessor = { child: { connected: true, send: metricsMock } } as unknown as TelemetryProcess
55+
beforeEach(() => metricsMock.mockClear())
56+
57+
test('event attribute included', () => {
58+
const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' })
59+
recorder.recordEvent('tasks.completed', { success: true })
60+
expect(metricsMock).toHaveBeenCalledWith(
61+
expect.objectContaining({ data: expect.objectContaining({ foo: 'bar' }) })
62+
)
63+
})
64+
65+
test('parent attributes inherited', () => {
66+
const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' })
67+
const child = recorder.scoped({ baz: 'qux' })
68+
child.recordEvent('tasks.completed', { success: true })
69+
expect(metricsMock).toHaveBeenCalledWith(
70+
expect.objectContaining({ data: expect.objectContaining({ foo: 'bar', baz: 'qux' }) })
71+
)
72+
})
73+
74+
test('grandparent attributes inherited', () => {
75+
const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' })
76+
const grandchild = recorder.scoped({ baz: 'qux' }).scoped({ test: 'pass' })
77+
grandchild.recordEvent('tasks.completed', { success: true })
78+
expect(metricsMock).toHaveBeenCalledWith(
79+
expect.objectContaining({ data: expect.objectContaining({ foo: 'bar', baz: 'qux', test: 'pass' }) })
80+
)
81+
})
82+
83+
test('parent attributes overridable', () => {
84+
const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' })
85+
const child = recorder.scoped({ foo: 'baz' })
86+
child.recordEvent('tasks.completed', { success: true })
87+
expect(metricsMock).toHaveBeenCalledWith(
88+
expect.objectContaining({ data: expect.objectContaining({ foo: 'baz' }) })
89+
)
90+
})
91+
92+
test("can't override event metadata", () => {
93+
const recorder = new TelemetryRecorder(mockProcessor, { namespace: 'foo', eventTimestamp: '137' })
94+
recorder.recordEvent('tasks.completed', { success: true })
95+
expect(metricsMock).toHaveBeenCalledWith(
96+
expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' })
97+
)
98+
expect(metricsMock).not.toHaveBeenCalledWith(expect.objectContaining({ eventTimestamp: '137' }))
99+
})
100+
})
101+
102+
describe('communication with child', () => {
103+
let mockServer: Server
104+
let telemetryChildProcess: ChildProcess
105+
106+
beforeEach(() => {
107+
mockServer = createAndRegisterMockServer()
108+
telemetryChildProcess = fork(`${__dirname}/metricsProcess.mjs`, { env: { ...process.env } })
109+
})
110+
afterEach(() => {
111+
mockServer.close()
112+
telemetryChildProcess.kill()
113+
})
114+
115+
test('metrics are still sent after parent has exited', async () => {
116+
telemetryChildProcess.send({ action: 'send' })
117+
telemetryChildProcess.send({ action: 'send' })
118+
telemetryChildProcess.send({ action: 'disconnect' })
119+
// first metric will only finish sending once we receive it here
120+
const metrics = await listenForTelemetry(mockServer, 2, 10)
121+
expect(metrics).toHaveLength(2)
122+
})
123+
})
124+
125+
describe('metrics sent', () => {
126+
let mockServer: Server
127+
let telemetryProcess: TelemetryProcess
128+
beforeEach(() => {
129+
mockServer = createAndRegisterMockServer()
130+
telemetryProcess = new TelemetryProcess(logger)
131+
})
132+
afterEach(() => {
133+
mockServer.close()
134+
telemetryProcess.disconnect()
135+
jest.useRealTimers()
136+
})
137+
138+
test('a metric is sent successfully', async () => {
139+
const listeningPromise = listenForTelemetry(mockServer, 1)
140+
telemetryProcess.root().recordEvent('tasks.completed', { success: true })
141+
const metrics = await listeningPromise
142+
expect(metrics).toEqual([[expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' })]])
143+
})
144+
145+
// TODO:IM:20260107 enable this test once we have multiple different metric types
146+
test.skip('metrics of different types are sent successfully', async () => {
147+
const listeningPromise = listenForTelemetry(mockServer, 2)
148+
const recorder = telemetryProcess.root()
149+
recorder.recordEvent('tasks.completed', { success: true })
150+
recorder.recordEvent('tasks.completed', { success: true })
151+
const metrics = await listeningPromise
152+
expect(metrics.flat()).toEqual([
153+
expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' }),
154+
expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' })
155+
])
156+
})
157+
158+
test('buffers multiple metrics sent together', async () => {
159+
const listeningPromise = listenForTelemetry(mockServer, 3, 10)
160+
const recorder = telemetryProcess.root()
161+
recorder.recordEvent('tasks.completed', { success: true })
162+
recorder.recordEvent('tasks.completed', { success: true })
163+
recorder.recordEvent('tasks.completed', { success: true })
164+
const metrics = await listeningPromise
165+
expect(metrics[1]).toHaveLength(2)
166+
})
167+
168+
test('uses timestamp from when recorded, not sent', async () => {
169+
jest.useFakeTimers({ now: 0 })
170+
const listeningPromise = listenForTelemetry(mockServer, 1)
171+
telemetryProcess.root().recordEvent('tasks.completed', { success: true })
172+
jest.setSystemTime(20)
173+
const metrics = await listeningPromise
174+
expect(metrics[0][0].eventTimestamp).toBe(0)
175+
})
176+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import winston from 'winston'
2+
3+
import { TelemetryProcess } from '../lib/index.js'
4+
5+
const telemetryProcess = new TelemetryProcess(winston)
6+
const metrics = telemetryProcess.root()
7+
8+
process.on('message', ({ action }) => {
9+
switch (action) {
10+
case 'send':
11+
metrics.recordEvent('tasks.completed', { success: true })
12+
break
13+
case 'disconnect':
14+
// unreference everything so that this process's event loop can exit.
15+
// explicitly disconnecting can mean that some messages are left unsent
16+
telemetryProcess.child.unref()
17+
telemetryProcess.child.channel?.unref()
18+
telemetryProcess.child.stderr?.destroy()
19+
}
20+
})

lib/telemetry/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"extends": "../../tsconfig.settings.json",
3+
"include": ["src/**/*"],
34
"compilerOptions": {
45
"outDir": "lib",
56
"rootDir": "src"

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)