@customerio/cdp-analytics-node 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.MD +22 -0
- package/README.md +20 -0
- package/dist/cjs/app/analytics-node.js +170 -0
- package/dist/cjs/app/analytics-node.js.map +1 -0
- package/dist/cjs/app/context.js +13 -0
- package/dist/cjs/app/context.js.map +1 -0
- package/dist/cjs/app/dispatch-emit.js +37 -0
- package/dist/cjs/app/dispatch-emit.js.map +1 -0
- package/dist/cjs/app/emitter.js +8 -0
- package/dist/cjs/app/emitter.js.map +1 -0
- package/dist/cjs/app/event-factory.js +12 -0
- package/dist/cjs/app/event-factory.js.map +1 -0
- package/dist/cjs/app/event-queue.js +24 -0
- package/dist/cjs/app/event-queue.js.map +1 -0
- package/dist/cjs/app/settings.js +11 -0
- package/dist/cjs/app/settings.js.map +1 -0
- package/dist/cjs/app/types/event.js +3 -0
- package/dist/cjs/app/types/event.js.map +1 -0
- package/dist/cjs/app/types/index.js +7 -0
- package/dist/cjs/app/types/index.js.map +1 -0
- package/dist/cjs/app/types/params.js +3 -0
- package/dist/cjs/app/types/params.js.map +1 -0
- package/dist/cjs/app/types/plugin.js +3 -0
- package/dist/cjs/app/types/plugin.js.map +1 -0
- package/dist/cjs/generated/version.js +6 -0
- package/dist/cjs/generated/version.js.map +1 -0
- package/dist/cjs/index.js +11 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/lib/abort.js +77 -0
- package/dist/cjs/lib/abort.js.map +1 -0
- package/dist/cjs/lib/base-64-encode.js +19 -0
- package/dist/cjs/lib/base-64-encode.js.map +1 -0
- package/dist/cjs/lib/create-url.js +15 -0
- package/dist/cjs/lib/create-url.js.map +1 -0
- package/dist/cjs/lib/env.js +25 -0
- package/dist/cjs/lib/env.js.map +1 -0
- package/dist/cjs/lib/extract-promise-parts.js +21 -0
- package/dist/cjs/lib/extract-promise-parts.js.map +1 -0
- package/dist/cjs/lib/fetch.js +7 -0
- package/dist/cjs/lib/fetch.js.map +1 -0
- package/dist/cjs/lib/get-message-id.js +14 -0
- package/dist/cjs/lib/get-message-id.js.map +1 -0
- package/dist/cjs/lib/uuid.js +6 -0
- package/dist/cjs/lib/uuid.js.map +1 -0
- package/dist/cjs/plugins/customerio/context-batch.js +55 -0
- package/dist/cjs/plugins/customerio/context-batch.js.map +1 -0
- package/dist/cjs/plugins/customerio/index.js +44 -0
- package/dist/cjs/plugins/customerio/index.js.map +1 -0
- package/dist/cjs/plugins/customerio/publisher.js +189 -0
- package/dist/cjs/plugins/customerio/publisher.js.map +1 -0
- package/dist/esm/app/analytics-node.js +166 -0
- package/dist/esm/app/analytics-node.js.map +1 -0
- package/dist/esm/app/context.js +9 -0
- package/dist/esm/app/context.js.map +1 -0
- package/dist/esm/app/dispatch-emit.js +33 -0
- package/dist/esm/app/dispatch-emit.js.map +1 -0
- package/dist/esm/app/emitter.js +4 -0
- package/dist/esm/app/emitter.js.map +1 -0
- package/dist/esm/app/event-factory.js +8 -0
- package/dist/esm/app/event-factory.js.map +1 -0
- package/dist/esm/app/event-queue.js +20 -0
- package/dist/esm/app/event-queue.js.map +1 -0
- package/dist/esm/app/settings.js +7 -0
- package/dist/esm/app/settings.js.map +1 -0
- package/dist/esm/app/types/event.js +2 -0
- package/dist/esm/app/types/event.js.map +1 -0
- package/dist/esm/app/types/index.js +4 -0
- package/dist/esm/app/types/index.js.map +1 -0
- package/dist/esm/app/types/params.js +2 -0
- package/dist/esm/app/types/params.js.map +1 -0
- package/dist/esm/app/types/plugin.js +2 -0
- package/dist/esm/app/types/plugin.js.map +1 -0
- package/dist/esm/generated/version.js +3 -0
- package/dist/esm/generated/version.js.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lib/abort.js +73 -0
- package/dist/esm/lib/abort.js.map +1 -0
- package/dist/esm/lib/base-64-encode.js +15 -0
- package/dist/esm/lib/base-64-encode.js.map +1 -0
- package/dist/esm/lib/create-url.js +11 -0
- package/dist/esm/lib/create-url.js.map +1 -0
- package/dist/esm/lib/env.js +21 -0
- package/dist/esm/lib/env.js.map +1 -0
- package/dist/esm/lib/extract-promise-parts.js +17 -0
- package/dist/esm/lib/extract-promise-parts.js.map +1 -0
- package/dist/esm/lib/fetch.js +3 -0
- package/dist/esm/lib/fetch.js.map +1 -0
- package/dist/esm/lib/get-message-id.js +10 -0
- package/dist/esm/lib/get-message-id.js.map +1 -0
- package/dist/esm/lib/uuid.js +2 -0
- package/dist/esm/lib/uuid.js.map +1 -0
- package/dist/esm/plugins/customerio/context-batch.js +51 -0
- package/dist/esm/plugins/customerio/context-batch.js.map +1 -0
- package/dist/esm/plugins/customerio/index.js +39 -0
- package/dist/esm/plugins/customerio/index.js.map +1 -0
- package/dist/esm/plugins/customerio/publisher.js +185 -0
- package/dist/esm/plugins/customerio/publisher.js.map +1 -0
- package/dist/types/app/analytics-node.d.ts +62 -0
- package/dist/types/app/analytics-node.d.ts.map +1 -0
- package/dist/types/app/context.d.ts +6 -0
- package/dist/types/app/context.d.ts.map +1 -0
- package/dist/types/app/dispatch-emit.d.ts +7 -0
- package/dist/types/app/dispatch-emit.d.ts.map +1 -0
- package/dist/types/app/emitter.d.ts +23 -0
- package/dist/types/app/emitter.d.ts.map +1 -0
- package/dist/types/app/event-factory.d.ts +14 -0
- package/dist/types/app/event-factory.d.ts.map +1 -0
- package/dist/types/app/event-queue.d.ts +7 -0
- package/dist/types/app/event-queue.d.ts.map +1 -0
- package/dist/types/app/settings.d.ts +33 -0
- package/dist/types/app/settings.d.ts.map +1 -0
- package/dist/types/app/types/event.d.ts +7 -0
- package/dist/types/app/types/event.d.ts.map +1 -0
- package/dist/types/app/types/index.d.ts +4 -0
- package/dist/types/app/types/index.d.ts.map +1 -0
- package/dist/types/app/types/params.d.ts +60 -0
- package/dist/types/app/types/params.d.ts.map +1 -0
- package/dist/types/app/types/plugin.d.ts +6 -0
- package/dist/types/app/types/plugin.d.ts.map +1 -0
- package/dist/types/generated/version.d.ts +2 -0
- package/dist/types/generated/version.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/lib/abort.d.ts +6 -0
- package/dist/types/lib/abort.d.ts.map +1 -0
- package/dist/types/lib/base-64-encode.d.ts +5 -0
- package/dist/types/lib/base-64-encode.d.ts.map +1 -0
- package/dist/types/lib/create-url.d.ts +8 -0
- package/dist/types/lib/create-url.d.ts.map +1 -0
- package/dist/types/lib/env.d.ts +3 -0
- package/dist/types/lib/env.d.ts.map +1 -0
- package/dist/types/lib/extract-promise-parts.d.ts +9 -0
- package/dist/types/lib/extract-promise-parts.d.ts.map +1 -0
- package/dist/types/lib/fetch.d.ts +2 -0
- package/dist/types/lib/fetch.d.ts.map +1 -0
- package/dist/types/lib/get-message-id.d.ts +7 -0
- package/dist/types/lib/get-message-id.d.ts.map +1 -0
- package/dist/types/lib/uuid.d.ts +2 -0
- package/dist/types/lib/uuid.d.ts.map +1 -0
- package/dist/types/plugins/customerio/context-batch.d.ts +26 -0
- package/dist/types/plugins/customerio/context-batch.d.ts.map +1 -0
- package/dist/types/plugins/customerio/index.d.ts +13 -0
- package/dist/types/plugins/customerio/index.d.ts.map +1 -0
- package/dist/types/plugins/customerio/publisher.d.ts +38 -0
- package/dist/types/plugins/customerio/publisher.d.ts.map +1 -0
- package/package.json +43 -0
- package/src/app/analytics-node.ts +295 -0
- package/src/app/context.ts +11 -0
- package/src/app/dispatch-emit.ts +42 -0
- package/src/app/emitter.ts +23 -0
- package/src/app/event-factory.ts +20 -0
- package/src/app/event-queue.ts +23 -0
- package/src/app/settings.ts +39 -0
- package/src/app/types/event.ts +7 -0
- package/src/app/types/index.ts +3 -0
- package/src/app/types/params.ts +74 -0
- package/src/app/types/plugin.ts +5 -0
- package/src/generated/version.ts +2 -0
- package/src/index.ts +17 -0
- package/src/lib/abort.ts +77 -0
- package/src/lib/base-64-encode.ts +14 -0
- package/src/lib/create-url.ts +11 -0
- package/src/lib/env.ts +32 -0
- package/src/lib/extract-promise-parts.ts +21 -0
- package/src/lib/fetch.ts +3 -0
- package/src/lib/get-message-id.ts +10 -0
- package/src/lib/uuid.ts +1 -0
- package/src/plugins/customerio/context-batch.ts +71 -0
- package/src/plugins/customerio/index.ts +65 -0
- package/src/plugins/customerio/publisher.ts +258 -0
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type RuntimeEnv =
|
|
2
|
+
| 'node'
|
|
3
|
+
| 'browser'
|
|
4
|
+
| 'web-worker'
|
|
5
|
+
| 'cloudflare-worker'
|
|
6
|
+
| 'unknown'
|
|
7
|
+
|
|
8
|
+
export const detectRuntime = (): RuntimeEnv => {
|
|
9
|
+
if (typeof process === 'object' && process && process.env) {
|
|
10
|
+
return 'node'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof window === 'object') {
|
|
14
|
+
return 'browser'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// @ts-ignore
|
|
18
|
+
if (typeof WebSocketPair !== 'undefined') {
|
|
19
|
+
return 'cloudflare-worker'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
// @ts-ignore
|
|
24
|
+
typeof WorkerGlobalScope !== 'undefined' &&
|
|
25
|
+
// @ts-ignore
|
|
26
|
+
typeof importScripts === 'function'
|
|
27
|
+
) {
|
|
28
|
+
return 'web-worker'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return 'unknown'
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a promise and its associated `resolve` and `reject` methods.
|
|
3
|
+
*/
|
|
4
|
+
export function extractPromiseParts<T = unknown>(): {
|
|
5
|
+
promise: Promise<T>
|
|
6
|
+
resolve: (value: T) => void
|
|
7
|
+
reject: (reason?: unknown) => void
|
|
8
|
+
} {
|
|
9
|
+
let resolver: (value: T) => void
|
|
10
|
+
let rejecter: (reason?: unknown) => void
|
|
11
|
+
const promise = new Promise<T>((resolve, reject) => {
|
|
12
|
+
resolver = resolve
|
|
13
|
+
rejecter = reject
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
promise,
|
|
18
|
+
resolve: resolver!,
|
|
19
|
+
reject: rejecter!,
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/lib/fetch.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { uuid } from './uuid'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* get a unique messageId with a very low chance of collisions
|
|
5
|
+
* using @lukeed/uuid/secure uses the node crypto module, which is the fastest
|
|
6
|
+
* @example "node-next-1668208232027-743be593-7789-4b74-8078-cbcc8894c586"
|
|
7
|
+
*/
|
|
8
|
+
export const createMessageId = (): string => {
|
|
9
|
+
return `node-next-${Date.now()}-${uuid()}`
|
|
10
|
+
}
|
package/src/lib/uuid.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { v4 as uuid } from 'uuid'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { v4 as uuid } from '@lukeed/uuid'
|
|
2
|
+
import type { Context } from '../../app/context'
|
|
3
|
+
import { CustomerioEvent } from '../../app/types'
|
|
4
|
+
|
|
5
|
+
const MAX_EVENT_SIZE_IN_KB = 32
|
|
6
|
+
const MAX_BATCH_SIZE_IN_KB = 480 // (500 KB is the limit, leaving some padding)
|
|
7
|
+
|
|
8
|
+
interface PendingItem {
|
|
9
|
+
resolver: (ctx: Context) => void
|
|
10
|
+
context: Context
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ContextBatch {
|
|
14
|
+
public id = uuid()
|
|
15
|
+
private items: PendingItem[] = []
|
|
16
|
+
private sizeInBytes = 0
|
|
17
|
+
private maxEventCount: number
|
|
18
|
+
|
|
19
|
+
constructor(maxEventCount: number) {
|
|
20
|
+
this.maxEventCount = Math.max(1, maxEventCount)
|
|
21
|
+
}
|
|
22
|
+
public tryAdd(
|
|
23
|
+
item: PendingItem
|
|
24
|
+
): { success: true } | { success: false; message: string } {
|
|
25
|
+
if (this.length === this.maxEventCount)
|
|
26
|
+
return {
|
|
27
|
+
success: false,
|
|
28
|
+
message: `Event limit of ${this.maxEventCount} has been exceeded.`,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const eventSize = this.calculateSize(item.context)
|
|
32
|
+
if (eventSize > MAX_EVENT_SIZE_IN_KB * 1024) {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
message: `Event exceeds maximum event size of ${MAX_EVENT_SIZE_IN_KB} KB`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (this.sizeInBytes + eventSize > MAX_BATCH_SIZE_IN_KB * 1024) {
|
|
40
|
+
return {
|
|
41
|
+
success: false,
|
|
42
|
+
message: `Event has caused batch size to exceed ${MAX_BATCH_SIZE_IN_KB} KB`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.items.push(item)
|
|
47
|
+
this.sizeInBytes += eventSize
|
|
48
|
+
return { success: true }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get length(): number {
|
|
52
|
+
return this.items.length
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private calculateSize(ctx: Context): number {
|
|
56
|
+
return encodeURI(JSON.stringify(ctx.event)).split(/%..|i/).length
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getEvents(): CustomerioEvent[] {
|
|
60
|
+
const events = this.items.map(({ context }) => context.event)
|
|
61
|
+
return events
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getContexts(): Context[] {
|
|
65
|
+
return this.items.map((item) => item.context)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
resolveEvents(): void {
|
|
69
|
+
this.items.forEach(({ resolver, context }) => resolver(context))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Publisher, PublisherProps } from './publisher'
|
|
2
|
+
import { version } from '../../generated/version'
|
|
3
|
+
import { detectRuntime } from '../../lib/env'
|
|
4
|
+
import { Plugin } from '../../app/types'
|
|
5
|
+
import { Context } from '../../app/context'
|
|
6
|
+
import { NodeEmitter } from '../../app/emitter'
|
|
7
|
+
|
|
8
|
+
function normalizeEvent(ctx: Context) {
|
|
9
|
+
ctx.updateEvent('context.library.name', '@customerio/cdp-analytics-node')
|
|
10
|
+
ctx.updateEvent('context.library.version', version)
|
|
11
|
+
const runtime = detectRuntime()
|
|
12
|
+
if (runtime === 'node') {
|
|
13
|
+
ctx.updateEvent('_metadata.nodeVersion', process.versions.node)
|
|
14
|
+
}
|
|
15
|
+
ctx.updateEvent('_metadata.jsRuntime', runtime)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type DefinedPluginFields =
|
|
19
|
+
| 'name'
|
|
20
|
+
| 'type'
|
|
21
|
+
| 'version'
|
|
22
|
+
| 'isLoaded'
|
|
23
|
+
| 'load'
|
|
24
|
+
| 'alias'
|
|
25
|
+
| 'group'
|
|
26
|
+
| 'identify'
|
|
27
|
+
| 'page'
|
|
28
|
+
| 'screen'
|
|
29
|
+
| 'track'
|
|
30
|
+
|
|
31
|
+
type CustomerioNodePlugin = Plugin & Required<Pick<Plugin, DefinedPluginFields>>
|
|
32
|
+
|
|
33
|
+
export type ConfigureNodePluginProps = PublisherProps
|
|
34
|
+
|
|
35
|
+
export function createNodePlugin(publisher: Publisher): CustomerioNodePlugin {
|
|
36
|
+
function action(ctx: Context): Promise<Context> {
|
|
37
|
+
normalizeEvent(ctx)
|
|
38
|
+
return publisher.enqueue(ctx)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: 'Customer.io Data Pipelines',
|
|
43
|
+
type: 'after',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
isLoaded: () => true,
|
|
46
|
+
load: () => Promise.resolve(),
|
|
47
|
+
alias: action,
|
|
48
|
+
group: action,
|
|
49
|
+
identify: action,
|
|
50
|
+
page: action,
|
|
51
|
+
screen: action,
|
|
52
|
+
track: action,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const createConfiguredNodePlugin = (
|
|
57
|
+
props: ConfigureNodePluginProps,
|
|
58
|
+
emitter: NodeEmitter
|
|
59
|
+
) => {
|
|
60
|
+
const publisher = new Publisher(props, emitter)
|
|
61
|
+
return {
|
|
62
|
+
publisher: publisher,
|
|
63
|
+
plugin: createNodePlugin(publisher),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { backoff } from '@customerio/cdp-analytics-core'
|
|
2
|
+
import { abortSignalAfterTimeout } from '../../lib/abort'
|
|
3
|
+
import type { Context } from '../../app/context'
|
|
4
|
+
import { tryCreateFormattedUrl } from '../../lib/create-url'
|
|
5
|
+
import { extractPromiseParts } from '../../lib/extract-promise-parts'
|
|
6
|
+
import { fetch } from '../../lib/fetch'
|
|
7
|
+
import { ContextBatch } from './context-batch'
|
|
8
|
+
import { NodeEmitter } from '../../app/emitter'
|
|
9
|
+
import { b64encode } from '../../lib/base-64-encode'
|
|
10
|
+
|
|
11
|
+
function sleep(timeoutInMs: number): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, timeoutInMs))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function noop() { }
|
|
16
|
+
|
|
17
|
+
interface PendingItem {
|
|
18
|
+
resolver: (ctx: Context) => void
|
|
19
|
+
context: Context
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublisherProps {
|
|
23
|
+
host?: string
|
|
24
|
+
path?: string
|
|
25
|
+
flushInterval: number
|
|
26
|
+
maxEventsInBatch: number
|
|
27
|
+
maxRetries: number
|
|
28
|
+
writeKey: string
|
|
29
|
+
httpRequestTimeout?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Publisher is responsible for batching events and sending them to the API.
|
|
34
|
+
*/
|
|
35
|
+
export class Publisher {
|
|
36
|
+
private pendingFlushTimeout?: ReturnType<typeof setTimeout>
|
|
37
|
+
private _batch?: ContextBatch
|
|
38
|
+
|
|
39
|
+
private _flushInterval: number
|
|
40
|
+
private _maxEventsInBatch: number
|
|
41
|
+
private _maxRetries: number
|
|
42
|
+
private _auth: string
|
|
43
|
+
private _url: string
|
|
44
|
+
private _closeAndFlushPendingItemsCount?: number
|
|
45
|
+
private _httpRequestTimeout: number
|
|
46
|
+
private _emitter: NodeEmitter
|
|
47
|
+
constructor(
|
|
48
|
+
{
|
|
49
|
+
host,
|
|
50
|
+
path,
|
|
51
|
+
maxRetries,
|
|
52
|
+
maxEventsInBatch,
|
|
53
|
+
flushInterval,
|
|
54
|
+
writeKey,
|
|
55
|
+
httpRequestTimeout,
|
|
56
|
+
}: PublisherProps,
|
|
57
|
+
emitter: NodeEmitter
|
|
58
|
+
) {
|
|
59
|
+
this._emitter = emitter
|
|
60
|
+
this._maxRetries = maxRetries
|
|
61
|
+
this._maxEventsInBatch = Math.max(maxEventsInBatch, 1)
|
|
62
|
+
this._flushInterval = flushInterval
|
|
63
|
+
this._auth = b64encode(`${writeKey}:`)
|
|
64
|
+
this._url = tryCreateFormattedUrl(
|
|
65
|
+
host ?? 'https://cdp.customer.io',
|
|
66
|
+
path ?? '/v1/batch'
|
|
67
|
+
)
|
|
68
|
+
this._httpRequestTimeout = httpRequestTimeout ?? 10000
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private createBatch(): ContextBatch {
|
|
72
|
+
this.pendingFlushTimeout && clearTimeout(this.pendingFlushTimeout)
|
|
73
|
+
const batch = new ContextBatch(this._maxEventsInBatch)
|
|
74
|
+
this._batch = batch
|
|
75
|
+
this.pendingFlushTimeout = setTimeout(() => {
|
|
76
|
+
if (batch === this._batch) {
|
|
77
|
+
this._batch = undefined
|
|
78
|
+
}
|
|
79
|
+
this.pendingFlushTimeout = undefined
|
|
80
|
+
if (batch.length) {
|
|
81
|
+
this.send(batch).catch(noop)
|
|
82
|
+
}
|
|
83
|
+
}, this._flushInterval)
|
|
84
|
+
return batch
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private clearBatch() {
|
|
88
|
+
this.pendingFlushTimeout && clearTimeout(this.pendingFlushTimeout)
|
|
89
|
+
this._batch = undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
flushAfterClose(pendingItemsCount: number) {
|
|
93
|
+
if (!pendingItemsCount) {
|
|
94
|
+
// if number of pending items is 0, there will never be anything else entering the batch, since the app is closed.
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this._closeAndFlushPendingItemsCount = pendingItemsCount
|
|
99
|
+
|
|
100
|
+
// if batch is empty, there's nothing to flush, and when things come in, enqueue will handle them.
|
|
101
|
+
if (!this._batch) return
|
|
102
|
+
|
|
103
|
+
// the number of globally pending items will always be larger or the same as batch size.
|
|
104
|
+
// Any mismatch is because some globally pending items are in plugins.
|
|
105
|
+
const isExpectingNoMoreItems = this._batch.length === pendingItemsCount
|
|
106
|
+
if (isExpectingNoMoreItems) {
|
|
107
|
+
this.send(this._batch).catch(noop)
|
|
108
|
+
this.clearBatch()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Enqueues the context for future delivery.
|
|
114
|
+
* @param ctx - Context containing an event.
|
|
115
|
+
* @returns a promise that resolves with the context after the event has been delivered.
|
|
116
|
+
*/
|
|
117
|
+
enqueue(ctx: Context): Promise<Context> {
|
|
118
|
+
const batch = this._batch ?? this.createBatch()
|
|
119
|
+
|
|
120
|
+
const { promise: ctxPromise, resolve } = extractPromiseParts<Context>()
|
|
121
|
+
|
|
122
|
+
const pendingItem: PendingItem = {
|
|
123
|
+
context: ctx,
|
|
124
|
+
resolver: resolve,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
The following logic ensures that a batch is never orphaned,
|
|
129
|
+
and is always sent before a new batch is created.
|
|
130
|
+
|
|
131
|
+
Add an event to the existing batch.
|
|
132
|
+
Success: Check if batch is full or no more items are expected to come in (i.e. closing). If so, send batch.
|
|
133
|
+
Failure: Assume event is too big to fit in current batch - send existing batch.
|
|
134
|
+
Add an event to the new batch.
|
|
135
|
+
Success: Check if batch is full and send if it is.
|
|
136
|
+
Failure: Event exceeds maximum size (it will never fit), fail the event.
|
|
137
|
+
*/
|
|
138
|
+
const addStatus = batch.tryAdd(pendingItem)
|
|
139
|
+
if (addStatus.success) {
|
|
140
|
+
const isExpectingNoMoreItems =
|
|
141
|
+
batch.length === this._closeAndFlushPendingItemsCount
|
|
142
|
+
const isFull = batch.length === this._maxEventsInBatch
|
|
143
|
+
if (isFull || isExpectingNoMoreItems) {
|
|
144
|
+
this.send(batch).catch(noop)
|
|
145
|
+
this.clearBatch()
|
|
146
|
+
}
|
|
147
|
+
return ctxPromise
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// If the new item causes the maximimum event size to be exceeded, send the current batch and create a new one.
|
|
151
|
+
if (batch.length) {
|
|
152
|
+
this.send(batch).catch(noop)
|
|
153
|
+
this.clearBatch()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fallbackBatch = this.createBatch()
|
|
157
|
+
|
|
158
|
+
const fbAddStatus = fallbackBatch.tryAdd(pendingItem)
|
|
159
|
+
|
|
160
|
+
if (fbAddStatus.success) {
|
|
161
|
+
const isExpectingNoMoreItems =
|
|
162
|
+
fallbackBatch.length === this._closeAndFlushPendingItemsCount
|
|
163
|
+
if (isExpectingNoMoreItems) {
|
|
164
|
+
this.send(fallbackBatch).catch(noop)
|
|
165
|
+
this.clearBatch()
|
|
166
|
+
}
|
|
167
|
+
return ctxPromise
|
|
168
|
+
} else {
|
|
169
|
+
// this should only occur if max event size is exceeded
|
|
170
|
+
ctx.setFailedDelivery({
|
|
171
|
+
reason: new Error(fbAddStatus.message),
|
|
172
|
+
})
|
|
173
|
+
return Promise.resolve(ctx)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async send(batch: ContextBatch) {
|
|
178
|
+
if (this._closeAndFlushPendingItemsCount) {
|
|
179
|
+
this._closeAndFlushPendingItemsCount -= batch.length
|
|
180
|
+
}
|
|
181
|
+
const events = batch.getEvents()
|
|
182
|
+
const payload = JSON.stringify({ batch: events })
|
|
183
|
+
const maxAttempts = this._maxRetries + 1
|
|
184
|
+
|
|
185
|
+
let currentAttempt = 0
|
|
186
|
+
while (currentAttempt < maxAttempts) {
|
|
187
|
+
currentAttempt++
|
|
188
|
+
|
|
189
|
+
let failureReason: unknown
|
|
190
|
+
const [signal, timeoutId] = abortSignalAfterTimeout(
|
|
191
|
+
this._httpRequestTimeout
|
|
192
|
+
)
|
|
193
|
+
try {
|
|
194
|
+
const requestInit = {
|
|
195
|
+
signal: signal,
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
Authorization: `Basic ${this._auth}`,
|
|
200
|
+
'User-Agent': 'cdp-analytics-node/latest',
|
|
201
|
+
},
|
|
202
|
+
body: payload,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this._emitter.emit('http_request', {
|
|
206
|
+
url: this._url,
|
|
207
|
+
method: requestInit.method,
|
|
208
|
+
headers: requestInit.headers,
|
|
209
|
+
body: requestInit.body,
|
|
210
|
+
})
|
|
211
|
+
const response = await fetch(this._url, requestInit)
|
|
212
|
+
|
|
213
|
+
clearTimeout(timeoutId)
|
|
214
|
+
|
|
215
|
+
if (response.ok) {
|
|
216
|
+
// Successfully sent events, so exit!
|
|
217
|
+
batch.resolveEvents()
|
|
218
|
+
return
|
|
219
|
+
} else if (response.status === 400) {
|
|
220
|
+
// Request either malformed or size exceeded - don't retry.
|
|
221
|
+
resolveFailedBatch(
|
|
222
|
+
batch,
|
|
223
|
+
new Error(`[${response.status}] ${response.statusText}`)
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
} else {
|
|
227
|
+
// Treat other errors as transient and retry.
|
|
228
|
+
failureReason = new Error(
|
|
229
|
+
`[${response.status}] ${response.statusText}`
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
// Network errors get thrown, retry them.
|
|
234
|
+
failureReason = err
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Final attempt failed, update context and resolve events.
|
|
238
|
+
if (currentAttempt === maxAttempts) {
|
|
239
|
+
resolveFailedBatch(batch, failureReason)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Retry after attempt-based backoff.
|
|
244
|
+
await sleep(
|
|
245
|
+
backoff({
|
|
246
|
+
attempt: currentAttempt,
|
|
247
|
+
minTimeout: 25,
|
|
248
|
+
maxTimeout: 1000,
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveFailedBatch(batch: ContextBatch, reason: unknown) {
|
|
256
|
+
batch.getContexts().forEach((ctx) => ctx.setFailedDelivery({ reason }))
|
|
257
|
+
batch.resolveEvents()
|
|
258
|
+
}
|