Skip to content

Commit 5bb3108

Browse files
authored
Merge pull request #1015 from appsignal/heartbeats
Implement heartbeats for Node.js
2 parents ae4a6a5 + 81bd0a9 commit 5bb3108

11 files changed

+535
-29
lines changed

.changesets/add-heartbeats-support.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
bump: "minor"
3+
type: "add"
4+
---
5+
6+
_Heartbeats are currently only available to beta testers. If you are interested in trying it out, [send an email to [email protected]](mailto:[email protected]?subject=Heartbeat%20beta)!_
7+
8+
---
9+
10+
Add heartbeats support. You can send heartbeats directly from your code, to
11+
track the execution of certain processes:
12+
13+
```javascript
14+
import { heartbeat } from "@appsignal/nodejs"
15+
16+
function sendInvoices() {
17+
// ... your code here ...
18+
heartbeat("send_invoices")
19+
}
20+
```
21+
22+
You can pass a function to `heartbeat`, to report to AppSignal both when the
23+
process starts, and when it finishes, allowing you to see the duration of the
24+
process:
25+
26+
```javascript
27+
import { heartbeat } from "@appsignal/nodejs"
28+
29+
function sendInvoices() {
30+
heartbeat("send_invoices", () => {
31+
// ... your code here ...
32+
})
33+
}
34+
```
35+
36+
If an exception is raised within the function, the finish event will not be
37+
reported to AppSignal, triggering a notification about the missing heartbeat.
38+
The exception will bubble outside of the heartbeat function.
39+
40+
If the function passed to `heartbeat` returns a promise, the finish event will
41+
be reported to AppSignal if the promise resolves. This means that you can use
42+
heartbeats to track the duration of async functions:
43+
44+
```javascript
45+
import { heartbeat } from "@appsignal/nodejs"
46+
47+
async function sendInvoices() {
48+
await heartbeat("send_invoices", async () => {
49+
// ... your async code here ...
50+
})
51+
}
52+
```
53+
54+
If the promise is rejected, or if it never resolves, the finish event will
55+
not be reported to AppSignal.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
bump: "patch"
3+
type: "change"
4+
---
5+
6+
`Appsignal.stop()` now returns a promise. For your application to wait until
7+
AppSignal has been gracefully stopped, this promise must be awaited:
8+
9+
```javascript
10+
import { Appsignal } from "@appsignal/nodejs"
11+
12+
await Appsignal.stop()
13+
process.exit(0)
14+
```
15+
16+
In older Node.js versions where top-level await is not available, terminate
17+
the application when the promise is settled:
18+
19+
```javascript
20+
import { Appsignal } from "@appsignal/nodejs"
21+
22+
Appsignal.stop().finally(() => {
23+
process.exit(0)
24+
})
25+
```

src/__tests__/client.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe("Client", () => {
2323
client = new Client({ ...DEFAULT_OPTS })
2424
})
2525

26-
afterEach(() => {
27-
client.stop()
26+
afterEach(async () => {
27+
await client.stop()
2828
})
2929

3030
it("starts the client", () => {
@@ -33,18 +33,18 @@ describe("Client", () => {
3333
expect(startSpy).toHaveBeenCalled()
3434
})
3535

36-
it("stops the client", () => {
36+
it("stops the client", async () => {
3737
const extensionStopSpy = jest.spyOn(Extension.prototype, "stop")
38-
client.stop()
38+
await client.stop()
3939
expect(extensionStopSpy).toHaveBeenCalled()
4040
})
4141

42-
it("stops the probes when the client is active", () => {
42+
it("stops the probes when the client is active", async () => {
4343
client = new Client({ ...DEFAULT_OPTS, active: true })
4444
const probes = client.metrics().probes()
4545
expect(probes.isRunning).toEqual(true)
4646

47-
client.stop()
47+
await client.stop()
4848
expect(probes.isRunning).toEqual(false)
4949
})
5050

src/__tests__/heartbeat.test.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import nock, { Scope } from "nock"
2+
import { heartbeat, Heartbeat, EventKind } from "../heartbeat"
3+
import { Client, Options } from "../client"
4+
5+
const DEFAULT_CLIENT_CONFIG: Partial<Options> = {
6+
active: true,
7+
name: "Test App",
8+
pushApiKey: "test-push-api-key",
9+
environment: "test",
10+
hostname: "test-hostname"
11+
}
12+
13+
function mockHeartbeatRequest(
14+
kind: EventKind,
15+
{ delay } = { delay: 0 }
16+
): Scope {
17+
return nock("https://appsignal-endpoint.net:443")
18+
.post("/heartbeats/json", body => {
19+
return body.name === "test-heartbeat" && body.kind === kind
20+
})
21+
.query({
22+
api_key: "test-push-api-key",
23+
name: "Test App",
24+
environment: "test",
25+
hostname: "test-hostname"
26+
})
27+
.delay(delay)
28+
.reply(200, "")
29+
}
30+
31+
function nextTick(fn: () => void): Promise<void> {
32+
return new Promise(resolve => {
33+
process.nextTick(() => {
34+
fn()
35+
resolve()
36+
})
37+
})
38+
}
39+
40+
function sleep(ms: number): Promise<void> {
41+
return new Promise(resolve => {
42+
setTimeout(resolve, ms)
43+
})
44+
}
45+
46+
function interceptRequestBody(scope: Scope): Promise<string> {
47+
return new Promise(resolve => {
48+
scope.on("request", (_req, _interceptor, body: string) => {
49+
resolve(body)
50+
})
51+
})
52+
}
53+
54+
describe("Heartbeat", () => {
55+
let client: Client
56+
let theHeartbeat: Heartbeat
57+
58+
beforeAll(() => {
59+
theHeartbeat = new Heartbeat("test-heartbeat")
60+
61+
if (!nock.isActive()) {
62+
nock.activate()
63+
}
64+
})
65+
66+
beforeEach(() => {
67+
client = new Client(DEFAULT_CLIENT_CONFIG)
68+
69+
nock.cleanAll()
70+
nock.disableNetConnect()
71+
})
72+
73+
afterEach(() => {
74+
client.stop()
75+
})
76+
77+
afterAll(() => {
78+
nock.restore()
79+
})
80+
81+
it("does not transmit any events when AppSignal is not active", async () => {
82+
client.stop()
83+
client = new Client({
84+
...DEFAULT_CLIENT_CONFIG,
85+
active: false
86+
})
87+
88+
const startScope = mockHeartbeatRequest("start")
89+
const finishScope = mockHeartbeatRequest("finish")
90+
91+
await expect(theHeartbeat.start()).resolves.toBeUndefined()
92+
await expect(theHeartbeat.finish()).resolves.toBeUndefined()
93+
94+
expect(startScope.isDone()).toBe(false)
95+
expect(finishScope.isDone()).toBe(false)
96+
})
97+
98+
it("heartbeat.start() sends a heartbeat start event", async () => {
99+
const scope = mockHeartbeatRequest("start")
100+
101+
await expect(theHeartbeat.start()).resolves.toBeUndefined()
102+
103+
scope.done()
104+
})
105+
106+
it("heartbeat.finish() sends a heartbeat finish event", async () => {
107+
const scope = mockHeartbeatRequest("finish")
108+
109+
await expect(theHeartbeat.finish()).resolves.toBeUndefined()
110+
111+
scope.done()
112+
})
113+
114+
it("Heartbeat.shutdown() awaits pending heartbeat event promises", async () => {
115+
const startScope = mockHeartbeatRequest("start", { delay: 100 })
116+
const finishScope = mockHeartbeatRequest("finish", { delay: 200 })
117+
118+
let finishPromiseResolved = false
119+
let shutdownPromiseResolved = false
120+
121+
const startPromise = theHeartbeat.start()
122+
123+
theHeartbeat.finish().then(() => {
124+
finishPromiseResolved = true
125+
})
126+
127+
const shutdownPromise = Heartbeat.shutdown().then(() => {
128+
shutdownPromiseResolved = true
129+
})
130+
131+
await expect(startPromise).resolves.toBeUndefined()
132+
133+
// The finish promise should still be pending, so the shutdown promise
134+
// should not be resolved yet.
135+
await nextTick(() => {
136+
expect(finishPromiseResolved).toBe(false)
137+
expect(shutdownPromiseResolved).toBe(false)
138+
})
139+
140+
startScope.done()
141+
142+
// The shutdown promise should not resolve until the finish promise
143+
// resolves.
144+
await expect(shutdownPromise).resolves.toBeUndefined()
145+
146+
await nextTick(() => {
147+
expect(finishPromiseResolved).toBe(true)
148+
})
149+
150+
finishScope.done()
151+
})
152+
153+
describe("Appsignal.heartbeat()", () => {
154+
it("without a function, sends a heartbeat finish event", async () => {
155+
const startScope = mockHeartbeatRequest("start")
156+
const finishScope = mockHeartbeatRequest("finish")
157+
158+
expect(heartbeat("test-heartbeat")).toBeUndefined()
159+
160+
await nextTick(() => {
161+
expect(startScope.isDone()).toBe(false)
162+
finishScope.done()
163+
})
164+
})
165+
166+
describe("with a function", () => {
167+
it("sends heartbeat start and finish events", async () => {
168+
const startScope = mockHeartbeatRequest("start")
169+
const startBody = interceptRequestBody(startScope)
170+
171+
const finishScope = mockHeartbeatRequest("finish")
172+
const finishBody = interceptRequestBody(finishScope)
173+
174+
expect(
175+
heartbeat("test-heartbeat", () => {
176+
const thisSecond = Math.floor(Date.now() / 1000)
177+
178+
// Since this function must be synchronous, we need to deadlock
179+
// until the next second in order to obtain different timestamps
180+
// for the start and finish events.
181+
// eslint-disable-next-line no-constant-condition
182+
while (true) {
183+
if (Math.floor(Date.now() / 1000) != thisSecond) break
184+
}
185+
186+
return "output"
187+
})
188+
).toBe("output")
189+
190+
// Since the function is synchronous and deadlocks, the start and
191+
// finish events' requests are actually initiated simultaneously
192+
// afterwards, when the function finishes and the event loop ticks.
193+
await nextTick(() => {
194+
startScope.done()
195+
finishScope.done()
196+
})
197+
198+
expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan(
199+
JSON.parse(await startBody).timestamp
200+
)
201+
})
202+
203+
it("does not send a finish event when the function throws an error", async () => {
204+
const startScope = mockHeartbeatRequest("start")
205+
const finishScope = mockHeartbeatRequest("finish")
206+
207+
expect(() => {
208+
heartbeat("test-heartbeat", () => {
209+
throw new Error("thrown")
210+
})
211+
}).toThrow("thrown")
212+
213+
await nextTick(() => {
214+
startScope.done()
215+
expect(finishScope.isDone()).toBe(false)
216+
})
217+
})
218+
})
219+
220+
describe("with an async function", () => {
221+
it("sends heartbeat start and finish events", async () => {
222+
const startScope = mockHeartbeatRequest("start")
223+
const startBody = interceptRequestBody(startScope)
224+
225+
const finishScope = mockHeartbeatRequest("finish")
226+
const finishBody = interceptRequestBody(finishScope)
227+
228+
await expect(
229+
heartbeat("test-heartbeat", async () => {
230+
await nextTick(() => {
231+
startScope.done()
232+
expect(finishScope.isDone()).toBe(false)
233+
})
234+
235+
const millisecondsToNextSecond = 1000 - (Date.now() % 1000)
236+
await sleep(millisecondsToNextSecond)
237+
238+
return "output"
239+
})
240+
).resolves.toBe("output")
241+
242+
await nextTick(() => {
243+
startScope.done()
244+
finishScope.done()
245+
})
246+
247+
expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan(
248+
JSON.parse(await startBody).timestamp
249+
)
250+
})
251+
252+
it("does not send a finish event when the promise returned is rejected", async () => {
253+
const startScope = mockHeartbeatRequest("start")
254+
const finishScope = mockHeartbeatRequest("finish")
255+
256+
await expect(
257+
heartbeat("test-heartbeat", async () => {
258+
await nextTick(() => {
259+
startScope.done()
260+
expect(finishScope.isDone()).toBe(false)
261+
})
262+
263+
throw new Error("rejected")
264+
})
265+
).rejects.toThrow("rejected")
266+
267+
await nextTick(() => {
268+
startScope.done()
269+
expect(finishScope.isDone()).toBe(false)
270+
})
271+
})
272+
})
273+
})
274+
})

0 commit comments

Comments
 (0)