Skip to content

Commit 6224018

Browse files
committed
Await heartbeat promises on shutdown
When AppSignal is "gracefully stopped" by calling `Appsignal.stop()`, the heartbeats might not have finished sending. AppSignal should await the delivery of those heartbeats before shutting down. To do this, the pending heartbeats are collected into a set, which automatically cleans itself up when all promises are settled. A promise for the settling of all remaining promises in the set is awaited when `Appsignal.stop()` is called. The method that awaits these promises is named `Heartbeat.shutdown()`, and not `Heartbeat.stop()`, to avoid any potential confusion where it could be mistaken for the counterpart of `Heartbeat.start()`. This is a sort of breaking change, in that `Appsignal.stop()` is now an asynchronous method, meaning that it returns a promise, and as such it must be awaited. However, note that `Appsignal.stop()` should have already been awaiting the promise returned by the OpenTelemetry SDK's `.shutdown()` method. As such, this change makes sense beyond the heartbeats.
1 parent 8cd55db commit 6224018

File tree

6 files changed

+76
-21
lines changed

6 files changed

+76
-21
lines changed
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

+6-6
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__/span_processor.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ describe("Span processor", () => {
3535
tracer = tracerProvider.getTracer("unknown-instrumentation")
3636
})
3737

38-
afterEach(() => {
39-
tracerProvider.shutdown()
40-
client.stop()
38+
afterEach(async () => {
39+
await tracerProvider.shutdown()
40+
await client.stop()
4141
})
4242

4343
it("processes unknown OpenTelemetry spans", () => {

src/__tests__/winston_transport.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ describe("BaseLogger", () => {
2020
})
2121
})
2222

23-
afterAll(() => {
24-
client.stop()
23+
afterAll(async () => {
24+
await client.stop()
2525
})
2626

2727
beforeEach(() => {

src/client.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
LayerType as RestifyLayerType
4444
} from "@opentelemetry/instrumentation-restify"
4545
import { SpanProcessor, TestModeSpanProcessor } from "./span_processor"
46+
import { Heartbeat } from "./heartbeat"
4647

4748
const DefaultInstrumentations = {
4849
"@opentelemetry/instrumentation-express": ExpressInstrumentation,
@@ -206,16 +207,17 @@ export class Client {
206207
* Call this before the end of your program to make sure the
207208
* agent is stopped as well.
208209
*/
209-
public stop(calledBy?: string): void {
210+
public async stop(calledBy?: string): Promise<void> {
210211
if (calledBy) {
211212
console.log(`Stopping AppSignal (${calledBy})`)
212213
} else {
213214
console.log("Stopping AppSignal")
214215
}
215216

216-
this.#sdk?.shutdown()
217+
await this.#sdk?.shutdown()
217218
this.metrics().probes().stop()
218219
this.extension.stop()
220+
await Heartbeat.shutdown()
219221
}
220222

221223
/**

src/heartbeat.ts

+36-8
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,21 @@ export type Event = {
1111
timestamp: number
1212
}
1313

14+
class PendingPromiseSet<T> extends Set<Promise<T>> {
15+
add(promise: Promise<T>) {
16+
super.add(promise)
17+
promise.finally(() => this.delete(promise))
18+
return this
19+
}
20+
21+
async allSettled() {
22+
await Promise.allSettled(this)
23+
}
24+
}
25+
1426
export class Heartbeat {
27+
private static heartbeatPromises = new PendingPromiseSet<any>()
28+
1529
name: string
1630
id: string
1731

@@ -20,6 +34,10 @@ export class Heartbeat {
2034
this.id = crypto.randomBytes(8).toString("hex")
2135
}
2236

37+
public static async shutdown() {
38+
await Heartbeat.heartbeatPromises.allSettled()
39+
}
40+
2341
public start() {
2442
this.transmit(this.event("start"))
2543
}
@@ -45,16 +63,26 @@ export class Heartbeat {
4563
return
4664
}
4765

48-
new Transmitter(
66+
const promise = new Transmitter(
4967
`${Client.config.data.loggingEndpoint}/heartbeats/json`,
5068
JSON.stringify(event)
51-
).transmit().then(({status}: {status: number}) => {
52-
if (status !== 200) {
53-
Client.internalLogger.warn(`Failed to transmit heartbeat: status code ${status}`)
54-
}
55-
}).catch(({error}: {error: Error}) => {
56-
Client.internalLogger.warn(`Failed to transmit heartbeat: ${error.message}`)
57-
})
69+
).transmit()
70+
71+
const handledPromise = promise
72+
.then(({ status }: { status: number }) => {
73+
if (status !== 200) {
74+
Client.internalLogger.warn(
75+
`Failed to transmit heartbeat: status code ${status}`
76+
)
77+
}
78+
})
79+
.catch(({ error }: { error: Error }) => {
80+
Client.internalLogger.warn(
81+
`Failed to transmit heartbeat: ${error.message}`
82+
)
83+
})
84+
85+
Heartbeat.heartbeatPromises.add(handledPromise)
5886
}
5987
}
6088

0 commit comments

Comments
 (0)