Skip to content

Commit a23ccee

Browse files
authored
add graceful shutdown (#604)
* add graceful shutdown Co-authored-by: Seth Silesky <[email protected]>
1 parent 9486cff commit a23ccee

File tree

15 files changed

+368
-50
lines changed

15 files changed

+368
-50
lines changed

internal/test-helpers/.eslintrc.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @type { import('eslint').Linter.Config } */
2+
module.exports = {
3+
extends: ['../../.eslintrc'],
4+
env: {
5+
node: true,
6+
},
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require("@internal/config").lintStagedConfig

internal/test-helpers/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# This is for code that will used as part of testing
2+
3+
There is no build step included because we use ts-jest, so this could gets compiled in-memory.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const { createJestTSConfig } = require('@internal/config')
2+
3+
module.exports = createJestTSConfig()

internal/test-helpers/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@internal/test-helpers",
3+
"version": "0.0.0",
4+
"private": true,
5+
"engines": {
6+
"node": ">=12"
7+
},
8+
"scripts": {
9+
"lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'",
10+
"tsc": "yarn run -T tsc",
11+
"eslint": "yarn run -T eslint",
12+
"concurrently": "yarn run -T concurrently"
13+
},
14+
"dependencies": {
15+
"tslib": "^2.4.0"
16+
},
17+
"packageManager": "[email protected]",
18+
"devDependencies": {
19+
"@internal/config": "0.0.0"
20+
}
21+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"exclude": ["node_modules", "dist"],
4+
"compilerOptions": {
5+
"resolveJsonModule": true,
6+
"module": "esnext",
7+
"target": "ES5",
8+
"moduleResolution": "node",
9+
"lib": ["es2020"]
10+
}
11+
}

packages/core/src/callback/index.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import { CoreContext } from '../context'
22
import type { Callback } from '../events'
33

4-
export function pTimeout(
5-
cb: Promise<unknown>,
6-
timeout: number
7-
): Promise<unknown> {
4+
export function pTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
85
return new Promise((resolve, reject) => {
96
const timeoutId = setTimeout(() => {
107
reject(Error('Promise timed out'))
118
}, timeout)
129

13-
cb.then((val) => {
14-
clearTimeout(timeoutId)
15-
return resolve(val)
16-
}).catch(reject)
10+
promise
11+
.then((val) => {
12+
clearTimeout(timeoutId)
13+
return resolve(val)
14+
})
15+
.catch(reject)
1716
})
1817
}
1918

20-
function sleep(timeoutInMs: number): Promise<void> {
19+
export function sleep(timeoutInMs: number): Promise<void> {
2120
return new Promise((resolve) => setTimeout(resolve, timeoutInMs))
2221
}
2322

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './plugins'
44
export * from './plugins/middleware'
55
export * from './events/interfaces'
66
export * from './events'
7+
export * from './callback'
78
export * from './priority-queue'
89
export * from './context'
910
export * from './queue/event-queue'

packages/node/README.md

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,94 @@
1-
# TODO: API Documentation is out of date
21

3-
https://segment.com/docs/connections/sources/catalog/libraries/server/node/
2+
## Warning: Until 1.x release, use this library at your own risk!
3+
While the API is very similar, the documentation for the legacy SDK (`analytics-node`) is here: https://segment.com/docs/connections/sources/catalog/libraries/server/node/
44

55

6-
NOTE: @segment/analytics-node is unstable! do not use.
6+
## Quick Start
7+
### Install library
8+
```bash
9+
# npm
10+
npm install @segment/analytics-node
11+
# yarn
12+
yarn add @segment/analytics-node
13+
# pnpm
14+
pnpm install @segment/analytics-node
15+
```
716

8-
## Basic Usage
17+
### Usage (assuming some express-like web framework)
918
```ts
10-
// analytics.ts
1119
import { AnalyticsNode } from '@segment/analytics-node'
1220

13-
export const analytics = new AnalyticsNode({ writeKey: '<MY_WRITE_KEY>' })
21+
const analytics = new AnalyticsNode({ writeKey: '<MY_WRITE_KEY>' })
1422

1523

16-
// app.ts
17-
import { analytics } from './analytics'
24+
app.post('/login', (req, res) => {
25+
analytics.identify({
26+
userId: req.body.userId,
27+
previousId: req.body.previousId
28+
})
29+
})
30+
31+
app.post('/cart', (req, res) => {
32+
analytics.track({
33+
userId: req.body.userId,
34+
event: 'Add to cart',
35+
properties: { productId: '123456' }
36+
})
37+
});
38+
```
39+
40+
## Graceful Shutdown
41+
### Avoid losing events on exit!
42+
* Call `.closeAndFlush()` to stop collecting new events and flush all existing events.
43+
* If a callback on an event call is included, this also waits for all callbacks to be called, and any of their subsequent promises to be resolved.
44+
```ts
45+
await analytics.closeAndFlush()
46+
// or
47+
await analytics.closeAndFlush({ timeout: 5000 }) // force resolve after 5000ms
48+
```
49+
### Graceful Shutdown: Advanced Example
50+
```ts
51+
import { AnalyticsNode } from '@segment/analytics-node'
52+
import express from 'express'
53+
54+
const analytics = new AnalyticsNode({ writeKey: '<MY_WRITE_KEY>' })
1855

19-
analytics.identify('Test User', { loggedIn: true }, { userId: "123456" })
20-
analytics.track('hello world', {}, { userId: "123456" })
56+
const app = express()
57+
app.post('/cart', (req, res) => {
58+
analytics.track({
59+
userId: req.body.userId,
60+
event: 'Add to cart',
61+
properties: { productId: '123456' }
62+
})
63+
});
2164

65+
const server = app.listen(3000)
66+
67+
68+
const onExit = async () => {
69+
console.log("Gracefully closing server...");
70+
await analytics.closeAndFlush() // flush all existing events
71+
server.close(() => process.exit());
72+
};
73+
74+
process.on("SIGINT", onExit);
75+
process.on("SIGTERM", onExit);
2276
```
2377

24-
# Event Emitter (Advanced Usage)
78+
#### Collecting unflushed events
79+
If you absolutely need to preserve all possible events in the event of a forced timeout, even ones that came in after `analytics.closeAndFlush()` was called, you can collect those events.
80+
```ts
81+
const unflushedEvents = []
82+
83+
analytics.on('call_after_close', (event) => unflushedEvents.push(events))
84+
await analytics.closeAndFlush()
85+
86+
console.log(unflushedEvents) // all events that came in after closeAndFlush was called
87+
88+
```
89+
90+
91+
## Event Emitter
2592
```ts
2693
import { analytics } from './analytics'
2794
import { ContextCancelation, CoreContext } from '@segment/analytics-node'
@@ -31,13 +98,12 @@ analytics.on('identify', (ctx) => console.log(ctx.event))
3198

3299
// listen for errors (if needed)
33100
analytics.on('error', (err) => {
34-
if (err instanceof ContextCancelation) {
35-
console.error('event cancelled', err.logs())
36-
} else if (err instanceof CoreContext) {
37-
console.error('event failed', err.logs())
101+
if (err.code === 'http_delivery') {
102+
console.error(err.response)
38103
} else {
39104
console.error(err)
40105
}
41106
})
42107
```
43108

109+
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { createSuccess } from './test-helpers/factories'
2+
3+
const fetcher = jest.fn().mockReturnValue(createSuccess())
4+
jest.mock('node-fetch', () => fetcher)
5+
6+
import { AnalyticsNode, NodeSegmentEvent } from '../app/analytics-node'
7+
import { sleep } from './test-helpers/sleep'
8+
import { CoreContext, CorePlugin } from '@segment/analytics-core'
9+
10+
const testPlugin: CorePlugin = {
11+
type: 'after',
12+
load: () => Promise.resolve(),
13+
name: 'foo',
14+
version: 'bar',
15+
isLoaded: () => true,
16+
}
17+
18+
describe('Ability for users to exit without losing events', () => {
19+
let ajs!: AnalyticsNode
20+
beforeEach(async () => {
21+
jest.resetAllMocks()
22+
ajs = new AnalyticsNode({
23+
writeKey: 'abc123',
24+
})
25+
})
26+
const _helpers = {
27+
makeTrackCall: (analytics = ajs, cb?: (...args: any[]) => void) => {
28+
analytics.track({ userId: 'foo', event: 'Thing Updated', callback: cb })
29+
},
30+
}
31+
32+
describe('drained emitted event', () => {
33+
test('emits a drained event if only one event is dispatched', async () => {
34+
_helpers.makeTrackCall()
35+
return expect(
36+
new Promise((resolve) => ajs.once('drained', () => resolve(undefined)))
37+
).resolves.toBe(undefined)
38+
})
39+
40+
test('emits a drained event if multiple events are dispatched', async () => {
41+
let drainedCalls = 0
42+
ajs.on('drained', () => {
43+
drainedCalls++
44+
})
45+
_helpers.makeTrackCall()
46+
_helpers.makeTrackCall()
47+
_helpers.makeTrackCall()
48+
await sleep(200)
49+
expect(drainedCalls).toBe(1)
50+
})
51+
52+
test('all callbacks should be called ', async () => {
53+
const cb = jest.fn()
54+
ajs.track({ userId: 'foo', event: 'bar', callback: cb })
55+
expect(cb).not.toHaveBeenCalled()
56+
await ajs.closeAndFlush()
57+
expect(cb).toBeCalled()
58+
})
59+
60+
test('all async callbacks should be called', async () => {
61+
const trackCall = new Promise<CoreContext>((resolve) =>
62+
ajs.track({
63+
userId: 'abc',
64+
event: 'def',
65+
callback: (ctx) => {
66+
return sleep(100).then(() => resolve(ctx))
67+
},
68+
})
69+
)
70+
const res = await Promise.race([ajs.closeAndFlush(), trackCall])
71+
expect(res instanceof CoreContext).toBe(true)
72+
})
73+
})
74+
75+
describe('.closeAndFlush()', () => {
76+
test('should force resolve if method call execution time exceeds specified timeout', async () => {
77+
const TIMEOUT = 300
78+
await ajs.register({
79+
...testPlugin,
80+
track: async (ctx) => {
81+
await sleep(1000)
82+
return ctx
83+
},
84+
})
85+
_helpers.makeTrackCall(ajs)
86+
const startTime = Date.now()
87+
await ajs.closeAndFlush({ timeout: TIMEOUT })
88+
const elapsedTime = Math.round(Date.now() - startTime)
89+
expect(elapsedTime).toBeLessThanOrEqual(TIMEOUT + 10)
90+
expect(elapsedTime).toBeGreaterThan(TIMEOUT - 10)
91+
})
92+
93+
test('no new events should be accepted (but existing ones should be flushed)', async () => {
94+
let trackCallCount = 0
95+
ajs.on('track', () => {
96+
// track should only happen after successful dispatch
97+
trackCallCount += 1
98+
})
99+
_helpers.makeTrackCall()
100+
const closed = ajs.closeAndFlush()
101+
_helpers.makeTrackCall() // should not trigger
102+
_helpers.makeTrackCall() // should not trigger
103+
await closed
104+
expect(fetcher).toBeCalledTimes(1)
105+
expect(trackCallCount).toBe(1)
106+
})
107+
test('any events created after close should be emitted', async () => {
108+
const events: NodeSegmentEvent[] = []
109+
ajs.on('call_after_close', (event) => {
110+
events.push(event)
111+
})
112+
_helpers.makeTrackCall()
113+
const closed = ajs.closeAndFlush()
114+
_helpers.makeTrackCall() // should be emitted
115+
_helpers.makeTrackCall() // should be emitted
116+
expect(events.length).toBe(2)
117+
expect(events.every((e) => e.type === 'track')).toBeTruthy()
118+
await closed
119+
})
120+
121+
test('if queue has multiple track events, all of those items should be dispatched, and drain and track events should be emitted', async () => {
122+
let drainedCalls = 0
123+
ajs.on('drained', () => {
124+
drainedCalls++
125+
})
126+
let trackCalls = 0
127+
ajs.on('track', () => {
128+
trackCalls++
129+
})
130+
await ajs.register({
131+
...testPlugin,
132+
track: async (ctx) => {
133+
await sleep(300)
134+
return ctx
135+
},
136+
})
137+
_helpers.makeTrackCall()
138+
_helpers.makeTrackCall()
139+
140+
await ajs.closeAndFlush()
141+
142+
expect(fetcher.mock.calls.length).toBe(2)
143+
144+
expect(trackCalls).toBe(2)
145+
146+
expect(drainedCalls).toBe(1)
147+
})
148+
149+
test('if no pending events, resolves immediately', async () => {
150+
const startTime = Date.now()
151+
await ajs.closeAndFlush()
152+
const elapsedTime = startTime - Date.now()
153+
expect(elapsedTime).toBeLessThan(20)
154+
})
155+
156+
test('if no pending events, drained should not be emitted an extra time when close is called', async () => {
157+
let called = false
158+
ajs.on('drained', () => {
159+
called = true
160+
})
161+
await ajs.closeAndFlush()
162+
expect(called).toBeFalsy()
163+
})
164+
})
165+
})

0 commit comments

Comments
 (0)