@drakkar.software/sunglasses-adapter-starfish 0.6.0 → 0.8.0
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/dist/index.d.mts +134 -93
- package/dist/index.d.ts +134 -93
- package/dist/index.js +33 -250
- package/dist/index.mjs +32 -246
- package/package.json +3 -3
package/dist/index.d.mts
CHANGED
|
@@ -1,120 +1,161 @@
|
|
|
1
|
-
import { IAnalyticsAdapter,
|
|
2
|
-
export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
|
|
1
|
+
import { IAnalyticsAdapter, SunglassesEvent } from '@drakkar.software/sunglasses-core';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
4
|
+
* SunGlasses → Starfish analytics adapter.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 3. POST /push/{path} → { data: updatedDoc, baseHash: hash }
|
|
12
|
-
* 4. On 409 Conflict (optimistic locking): pull again, re-merge, re-push
|
|
13
|
-
* (iterative, not recursive, to prevent stack overflow under high contention)
|
|
6
|
+
* Implements `IAnalyticsAdapter`: on each flush, maps the event batch to flat
|
|
7
|
+
* rows and pushes them as a JSON body to a Starfish events collection.
|
|
8
|
+
* The `starfish-events` server plugin intercepts the push and encodes Parquet
|
|
9
|
+
* on the server side — no Parquet dependency on the client.
|
|
14
10
|
*
|
|
15
|
-
* ##
|
|
16
|
-
* Skips the pull step entirely — events are pushed as a fresh document each time.
|
|
17
|
-
* Use for Starfish collections with `queueOnly: true` where pull always returns
|
|
18
|
-
* empty data and optimistic locking is not needed.
|
|
19
|
-
* On failure the adapter throws, keeping events in the local queue for retry.
|
|
11
|
+
* ## Transport
|
|
20
12
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* - No growing single document — each file stays small
|
|
26
|
-
* - Requires `pathStorage` in config to persist the generation counter
|
|
27
|
-
* - Combine with `enableLocalArchive: true` to keep a local copy of all events
|
|
13
|
+
* Uses an injected `StarfishPushClient` (structural type) so this package has
|
|
14
|
+
* **no runtime dependency on `@drakkar.software/starfish-client`** — avoiding
|
|
15
|
+
* an ESM-only/unpublished-alpha dep conflict. Construct a real `StarfishClient`
|
|
16
|
+
* externally and pass it in via `config.client`.
|
|
28
17
|
*
|
|
29
|
-
*
|
|
30
|
-
* with `distinctId ?? anonymousId` from the first event in the batch.
|
|
18
|
+
* ## At-least-once delivery
|
|
31
19
|
*
|
|
32
|
-
*
|
|
20
|
+
* `send()` throws on failure. SunglassesCore treats an adapter throw as a
|
|
21
|
+
* transient error and keeps the batch in the local queue for the next flush
|
|
22
|
+
* cycle — equivalent to the old `pushOnly` semantics.
|
|
23
|
+
*
|
|
24
|
+
* ## Privacy
|
|
25
|
+
*
|
|
26
|
+
* The SDK's consent gate and PiiSanitizer have already run before `send()` is
|
|
27
|
+
* called. This adapter must never log `distinct_id`, `properties`, or `context`
|
|
28
|
+
* (log counts only).
|
|
33
29
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
private readonly pushOnly;
|
|
41
|
-
private readonly rotatePathOnSuccess;
|
|
42
|
-
private readonly pathStorage;
|
|
43
|
-
constructor(config: StarfishAdapterConfig & {
|
|
44
|
-
timeoutMs?: number;
|
|
45
|
-
});
|
|
46
|
-
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
47
|
-
reset(): Promise<void>;
|
|
48
|
-
shutdown(): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal structural interface satisfied by `StarfishClient.push`.
|
|
33
|
+
* Declare it here so no import of `@drakkar.software/starfish-client` is needed.
|
|
34
|
+
*/
|
|
35
|
+
interface StarfishPushClient {
|
|
49
36
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
37
|
+
* Push a JSON document to a Starfish collection path.
|
|
38
|
+
* Must throw (or reject) on non-2xx so SunglassesCore retries.
|
|
39
|
+
*
|
|
40
|
+
* @param path Logical storage path (without the `/push/` prefix).
|
|
41
|
+
* @param data Document body — `{ events: StarfishEventRow[] }`.
|
|
42
|
+
* @param baseHash Pass `null` for "must not exist" (unique path per batch).
|
|
53
43
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
44
|
+
push(path: string, data: unknown, baseHash: string | null): Promise<unknown>;
|
|
45
|
+
}
|
|
46
|
+
/** Configuration for {@link StarfishAnalyticsAdapter}. */
|
|
47
|
+
interface StarfishAdapterConfig {
|
|
56
48
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
49
|
+
* A `StarfishClient` instance (or any object implementing `push`).
|
|
50
|
+
* Construct it with `{ baseUrl: "https://..." }` and no `capProvider` when
|
|
51
|
+
* targeting a `write: "public"` collection.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { StarfishClient } from "@drakkar.software/starfish-client"
|
|
56
|
+
* const client = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
|
|
57
|
+
* new StarfishAnalyticsAdapter({ client, app: "my-app" })
|
|
58
|
+
* ```
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
client: StarfishPushClient;
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
62
|
+
* Application/workspace identifier embedded in the storage path.
|
|
63
|
+
* Fills the `{app}` placeholder in `pathTemplate`.
|
|
64
|
+
* Keep it short and URL-safe (no `/` or spaces).
|
|
65
|
+
*/
|
|
66
|
+
app: string;
|
|
67
|
+
/**
|
|
68
|
+
* Storage-path template. Defaults to `"events/{app}/{batchId}"`.
|
|
69
|
+
* `{app}` → `config.app`; `{batchId}` → a UUID v4 generated per flush.
|
|
66
70
|
*
|
|
67
|
-
* The
|
|
68
|
-
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
71
|
+
* The server plugin appends `.parquet` automatically, so omit it here.
|
|
69
72
|
*/
|
|
70
|
-
|
|
71
|
-
private loadGeneration;
|
|
72
|
-
private saveGeneration;
|
|
73
|
-
private pull;
|
|
74
|
-
private push;
|
|
75
|
-
private buildHeaders;
|
|
73
|
+
pathTemplate?: string;
|
|
76
74
|
}
|
|
77
|
-
|
|
78
75
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
76
|
+
* SunGlasses analytics adapter that delivers event batches to a Starfish
|
|
77
|
+
* events collection, where the `starfish-events` plugin encodes them as
|
|
78
|
+
* Parquet and writes to S3.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* import { SunglassesCore } from "@drakkar.software/sunglasses-core"
|
|
83
|
+
* import { StarfishAnalyticsAdapter } from "@drakkar.software/sunglasses-adapter-starfish"
|
|
84
|
+
* import { StarfishClient } from "@drakkar.software/starfish-client"
|
|
85
|
+
*
|
|
86
|
+
* const sfClient = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
|
|
87
|
+
*
|
|
88
|
+
* const sg = await SunglassesCore.create({
|
|
89
|
+
* storage: myStorage,
|
|
90
|
+
* adapters: [
|
|
91
|
+
* new StarfishAnalyticsAdapter({ client: sfClient, app: "my-app" }),
|
|
92
|
+
* ],
|
|
93
|
+
* })
|
|
94
|
+
* ```
|
|
82
95
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
|
|
97
|
+
private readonly client;
|
|
98
|
+
private readonly app;
|
|
99
|
+
private readonly pathTemplate;
|
|
100
|
+
constructor(config: StarfishAdapterConfig);
|
|
101
|
+
private resolvePath;
|
|
102
|
+
/**
|
|
103
|
+
* Push a batch of events to the Starfish events collection.
|
|
104
|
+
*
|
|
105
|
+
* A unique `batchId` is generated per call, producing a unique storage path
|
|
106
|
+
* (one Parquet file per flush). `baseHash: null` signals "must not exist" to
|
|
107
|
+
* Starfish, ensuring no conflict on the unique path.
|
|
108
|
+
*
|
|
109
|
+
* Throws on failure — SunglassesCore keeps the batch in the local queue.
|
|
110
|
+
* Never logs event contents (distinct_id, properties, context).
|
|
111
|
+
*/
|
|
112
|
+
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
90
113
|
}
|
|
114
|
+
|
|
91
115
|
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
116
|
+
* Mapping utilities: SunglassesEvent → the flat EventRow shape that
|
|
117
|
+
* the starfish-events server plugin expects and encodes as Parquet columns.
|
|
118
|
+
*
|
|
119
|
+
* The column schema matches apps/ingest-server/src/schema.ts so a DuckDB
|
|
120
|
+
* query is identical regardless of which backend delivered the data.
|
|
121
|
+
*
|
|
122
|
+
* Privacy: distinct_id, properties, and context are mapped but must never
|
|
123
|
+
* be logged (they are opaque values that the SDK's PiiSanitizer already
|
|
124
|
+
* processed before the batch was handed to the adapter).
|
|
98
125
|
*/
|
|
99
|
-
|
|
126
|
+
|
|
100
127
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* resolveStoragePath('analytics/{identity}/events', 'user-123')
|
|
106
|
-
* // → 'analytics/user-123/events'
|
|
128
|
+
* Flat row shape sent to the Starfish events collection.
|
|
129
|
+
* All fields are strings; properties and context are JSON-serialized.
|
|
107
130
|
*/
|
|
108
|
-
|
|
131
|
+
interface StarfishEventRow {
|
|
132
|
+
/** EventType: 'capture' | 'screen' | 'identify' | 'alias' | 'group' */
|
|
133
|
+
event_type: string;
|
|
134
|
+
/** Human-readable event name, e.g. 'button_clicked' or '$screen'. */
|
|
135
|
+
event: string;
|
|
136
|
+
/** Resolved user identifier (hashed if anonymizeUserId is set). */
|
|
137
|
+
distinct_id: string;
|
|
138
|
+
/** Stable device UUID — safe for DAU / retention analysis. */
|
|
139
|
+
anonymous_id: string;
|
|
140
|
+
/** ISO-8601 UTC event timestamp. */
|
|
141
|
+
ts: string;
|
|
142
|
+
/** UUID v4 — use for deduplication. */
|
|
143
|
+
message_id: string;
|
|
144
|
+
/** JSON-serialized event properties (already PII-sanitized by the SDK). */
|
|
145
|
+
properties: string;
|
|
146
|
+
/** JSON-serialized EventContext (library/platform/app metadata). */
|
|
147
|
+
context: string;
|
|
148
|
+
/** 'YYYY-MM-DD' derived from ts — matches the Parquet partition key. */
|
|
149
|
+
dt: string;
|
|
150
|
+
}
|
|
109
151
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* Applied rules (in order):
|
|
113
|
-
* 1. Remove events older than `maxAgeMs` milliseconds.
|
|
114
|
-
* 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
|
|
152
|
+
* Map a single SunglassesEvent to the flat row format expected by the
|
|
153
|
+
* starfish-events plugin.
|
|
115
154
|
*
|
|
116
|
-
*
|
|
155
|
+
* `received_at` is intentionally omitted — the server plugin stamps it at
|
|
156
|
+
* ingest time so it reflects when the event landed on the server, not when
|
|
157
|
+
* the client serialised the batch.
|
|
117
158
|
*/
|
|
118
|
-
declare function
|
|
159
|
+
declare function toStarfishRow(e: SunglassesEvent): StarfishEventRow;
|
|
119
160
|
|
|
120
|
-
export { StarfishAnalyticsAdapter, type
|
|
161
|
+
export { type StarfishAdapterConfig, StarfishAnalyticsAdapter, type StarfishEventRow, type StarfishPushClient, toStarfishRow };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,120 +1,161 @@
|
|
|
1
|
-
import { IAnalyticsAdapter,
|
|
2
|
-
export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
|
|
1
|
+
import { IAnalyticsAdapter, SunglassesEvent } from '@drakkar.software/sunglasses-core';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
4
|
+
* SunGlasses → Starfish analytics adapter.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 3. POST /push/{path} → { data: updatedDoc, baseHash: hash }
|
|
12
|
-
* 4. On 409 Conflict (optimistic locking): pull again, re-merge, re-push
|
|
13
|
-
* (iterative, not recursive, to prevent stack overflow under high contention)
|
|
6
|
+
* Implements `IAnalyticsAdapter`: on each flush, maps the event batch to flat
|
|
7
|
+
* rows and pushes them as a JSON body to a Starfish events collection.
|
|
8
|
+
* The `starfish-events` server plugin intercepts the push and encodes Parquet
|
|
9
|
+
* on the server side — no Parquet dependency on the client.
|
|
14
10
|
*
|
|
15
|
-
* ##
|
|
16
|
-
* Skips the pull step entirely — events are pushed as a fresh document each time.
|
|
17
|
-
* Use for Starfish collections with `queueOnly: true` where pull always returns
|
|
18
|
-
* empty data and optimistic locking is not needed.
|
|
19
|
-
* On failure the adapter throws, keeping events in the local queue for retry.
|
|
11
|
+
* ## Transport
|
|
20
12
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* - No growing single document — each file stays small
|
|
26
|
-
* - Requires `pathStorage` in config to persist the generation counter
|
|
27
|
-
* - Combine with `enableLocalArchive: true` to keep a local copy of all events
|
|
13
|
+
* Uses an injected `StarfishPushClient` (structural type) so this package has
|
|
14
|
+
* **no runtime dependency on `@drakkar.software/starfish-client`** — avoiding
|
|
15
|
+
* an ESM-only/unpublished-alpha dep conflict. Construct a real `StarfishClient`
|
|
16
|
+
* externally and pass it in via `config.client`.
|
|
28
17
|
*
|
|
29
|
-
*
|
|
30
|
-
* with `distinctId ?? anonymousId` from the first event in the batch.
|
|
18
|
+
* ## At-least-once delivery
|
|
31
19
|
*
|
|
32
|
-
*
|
|
20
|
+
* `send()` throws on failure. SunglassesCore treats an adapter throw as a
|
|
21
|
+
* transient error and keeps the batch in the local queue for the next flush
|
|
22
|
+
* cycle — equivalent to the old `pushOnly` semantics.
|
|
23
|
+
*
|
|
24
|
+
* ## Privacy
|
|
25
|
+
*
|
|
26
|
+
* The SDK's consent gate and PiiSanitizer have already run before `send()` is
|
|
27
|
+
* called. This adapter must never log `distinct_id`, `properties`, or `context`
|
|
28
|
+
* (log counts only).
|
|
33
29
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
private readonly pushOnly;
|
|
41
|
-
private readonly rotatePathOnSuccess;
|
|
42
|
-
private readonly pathStorage;
|
|
43
|
-
constructor(config: StarfishAdapterConfig & {
|
|
44
|
-
timeoutMs?: number;
|
|
45
|
-
});
|
|
46
|
-
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
47
|
-
reset(): Promise<void>;
|
|
48
|
-
shutdown(): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal structural interface satisfied by `StarfishClient.push`.
|
|
33
|
+
* Declare it here so no import of `@drakkar.software/starfish-client` is needed.
|
|
34
|
+
*/
|
|
35
|
+
interface StarfishPushClient {
|
|
49
36
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
37
|
+
* Push a JSON document to a Starfish collection path.
|
|
38
|
+
* Must throw (or reject) on non-2xx so SunglassesCore retries.
|
|
39
|
+
*
|
|
40
|
+
* @param path Logical storage path (without the `/push/` prefix).
|
|
41
|
+
* @param data Document body — `{ events: StarfishEventRow[] }`.
|
|
42
|
+
* @param baseHash Pass `null` for "must not exist" (unique path per batch).
|
|
53
43
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
44
|
+
push(path: string, data: unknown, baseHash: string | null): Promise<unknown>;
|
|
45
|
+
}
|
|
46
|
+
/** Configuration for {@link StarfishAnalyticsAdapter}. */
|
|
47
|
+
interface StarfishAdapterConfig {
|
|
56
48
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
49
|
+
* A `StarfishClient` instance (or any object implementing `push`).
|
|
50
|
+
* Construct it with `{ baseUrl: "https://..." }` and no `capProvider` when
|
|
51
|
+
* targeting a `write: "public"` collection.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { StarfishClient } from "@drakkar.software/starfish-client"
|
|
56
|
+
* const client = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
|
|
57
|
+
* new StarfishAnalyticsAdapter({ client, app: "my-app" })
|
|
58
|
+
* ```
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
client: StarfishPushClient;
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
62
|
+
* Application/workspace identifier embedded in the storage path.
|
|
63
|
+
* Fills the `{app}` placeholder in `pathTemplate`.
|
|
64
|
+
* Keep it short and URL-safe (no `/` or spaces).
|
|
65
|
+
*/
|
|
66
|
+
app: string;
|
|
67
|
+
/**
|
|
68
|
+
* Storage-path template. Defaults to `"events/{app}/{batchId}"`.
|
|
69
|
+
* `{app}` → `config.app`; `{batchId}` → a UUID v4 generated per flush.
|
|
66
70
|
*
|
|
67
|
-
* The
|
|
68
|
-
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
71
|
+
* The server plugin appends `.parquet` automatically, so omit it here.
|
|
69
72
|
*/
|
|
70
|
-
|
|
71
|
-
private loadGeneration;
|
|
72
|
-
private saveGeneration;
|
|
73
|
-
private pull;
|
|
74
|
-
private push;
|
|
75
|
-
private buildHeaders;
|
|
73
|
+
pathTemplate?: string;
|
|
76
74
|
}
|
|
77
|
-
|
|
78
75
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
76
|
+
* SunGlasses analytics adapter that delivers event batches to a Starfish
|
|
77
|
+
* events collection, where the `starfish-events` plugin encodes them as
|
|
78
|
+
* Parquet and writes to S3.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* import { SunglassesCore } from "@drakkar.software/sunglasses-core"
|
|
83
|
+
* import { StarfishAnalyticsAdapter } from "@drakkar.software/sunglasses-adapter-starfish"
|
|
84
|
+
* import { StarfishClient } from "@drakkar.software/starfish-client"
|
|
85
|
+
*
|
|
86
|
+
* const sfClient = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
|
|
87
|
+
*
|
|
88
|
+
* const sg = await SunglassesCore.create({
|
|
89
|
+
* storage: myStorage,
|
|
90
|
+
* adapters: [
|
|
91
|
+
* new StarfishAnalyticsAdapter({ client: sfClient, app: "my-app" }),
|
|
92
|
+
* ],
|
|
93
|
+
* })
|
|
94
|
+
* ```
|
|
82
95
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
|
|
97
|
+
private readonly client;
|
|
98
|
+
private readonly app;
|
|
99
|
+
private readonly pathTemplate;
|
|
100
|
+
constructor(config: StarfishAdapterConfig);
|
|
101
|
+
private resolvePath;
|
|
102
|
+
/**
|
|
103
|
+
* Push a batch of events to the Starfish events collection.
|
|
104
|
+
*
|
|
105
|
+
* A unique `batchId` is generated per call, producing a unique storage path
|
|
106
|
+
* (one Parquet file per flush). `baseHash: null` signals "must not exist" to
|
|
107
|
+
* Starfish, ensuring no conflict on the unique path.
|
|
108
|
+
*
|
|
109
|
+
* Throws on failure — SunglassesCore keeps the batch in the local queue.
|
|
110
|
+
* Never logs event contents (distinct_id, properties, context).
|
|
111
|
+
*/
|
|
112
|
+
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
90
113
|
}
|
|
114
|
+
|
|
91
115
|
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
116
|
+
* Mapping utilities: SunglassesEvent → the flat EventRow shape that
|
|
117
|
+
* the starfish-events server plugin expects and encodes as Parquet columns.
|
|
118
|
+
*
|
|
119
|
+
* The column schema matches apps/ingest-server/src/schema.ts so a DuckDB
|
|
120
|
+
* query is identical regardless of which backend delivered the data.
|
|
121
|
+
*
|
|
122
|
+
* Privacy: distinct_id, properties, and context are mapped but must never
|
|
123
|
+
* be logged (they are opaque values that the SDK's PiiSanitizer already
|
|
124
|
+
* processed before the batch was handed to the adapter).
|
|
98
125
|
*/
|
|
99
|
-
|
|
126
|
+
|
|
100
127
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* resolveStoragePath('analytics/{identity}/events', 'user-123')
|
|
106
|
-
* // → 'analytics/user-123/events'
|
|
128
|
+
* Flat row shape sent to the Starfish events collection.
|
|
129
|
+
* All fields are strings; properties and context are JSON-serialized.
|
|
107
130
|
*/
|
|
108
|
-
|
|
131
|
+
interface StarfishEventRow {
|
|
132
|
+
/** EventType: 'capture' | 'screen' | 'identify' | 'alias' | 'group' */
|
|
133
|
+
event_type: string;
|
|
134
|
+
/** Human-readable event name, e.g. 'button_clicked' or '$screen'. */
|
|
135
|
+
event: string;
|
|
136
|
+
/** Resolved user identifier (hashed if anonymizeUserId is set). */
|
|
137
|
+
distinct_id: string;
|
|
138
|
+
/** Stable device UUID — safe for DAU / retention analysis. */
|
|
139
|
+
anonymous_id: string;
|
|
140
|
+
/** ISO-8601 UTC event timestamp. */
|
|
141
|
+
ts: string;
|
|
142
|
+
/** UUID v4 — use for deduplication. */
|
|
143
|
+
message_id: string;
|
|
144
|
+
/** JSON-serialized event properties (already PII-sanitized by the SDK). */
|
|
145
|
+
properties: string;
|
|
146
|
+
/** JSON-serialized EventContext (library/platform/app metadata). */
|
|
147
|
+
context: string;
|
|
148
|
+
/** 'YYYY-MM-DD' derived from ts — matches the Parquet partition key. */
|
|
149
|
+
dt: string;
|
|
150
|
+
}
|
|
109
151
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* Applied rules (in order):
|
|
113
|
-
* 1. Remove events older than `maxAgeMs` milliseconds.
|
|
114
|
-
* 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
|
|
152
|
+
* Map a single SunglassesEvent to the flat row format expected by the
|
|
153
|
+
* starfish-events plugin.
|
|
115
154
|
*
|
|
116
|
-
*
|
|
155
|
+
* `received_at` is intentionally omitted — the server plugin stamps it at
|
|
156
|
+
* ingest time so it reflects when the event landed on the server, not when
|
|
157
|
+
* the client serialised the batch.
|
|
117
158
|
*/
|
|
118
|
-
declare function
|
|
159
|
+
declare function toStarfishRow(e: SunglassesEvent): StarfishEventRow;
|
|
119
160
|
|
|
120
|
-
export { StarfishAnalyticsAdapter, type
|
|
161
|
+
export { type StarfishAdapterConfig, StarfishAnalyticsAdapter, type StarfishEventRow, type StarfishPushClient, toStarfishRow };
|
package/dist/index.js
CHANGED
|
@@ -21,276 +21,59 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
StarfishAnalyticsAdapter: () => StarfishAnalyticsAdapter,
|
|
24
|
-
|
|
25
|
-
mergeEvents: () => mergeEvents,
|
|
26
|
-
pruneDocument: () => pruneDocument,
|
|
27
|
-
resolveStoragePath: () => resolveStoragePath
|
|
24
|
+
toStarfishRow: () => toStarfishRow
|
|
28
25
|
});
|
|
29
26
|
module.exports = __toCommonJS(index_exports);
|
|
30
27
|
|
|
28
|
+
// src/StarfishAnalyticsAdapter.ts
|
|
29
|
+
var import_sunglasses_core = require("@drakkar.software/sunglasses-core");
|
|
30
|
+
|
|
31
31
|
// src/StarfishEventMapper.ts
|
|
32
|
-
function
|
|
33
|
-
return {
|
|
34
|
-
events: [],
|
|
35
|
-
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
36
|
-
version: "1"
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
function mergeEvents(remote, incoming) {
|
|
40
|
-
const existingIds = new Set(remote.events.map((e) => e.messageId));
|
|
41
|
-
const newEvents = incoming.filter((e) => !existingIds.has(e.messageId));
|
|
32
|
+
function toStarfishRow(e) {
|
|
42
33
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let events = [...doc.events];
|
|
53
|
-
if (config.maxAgeMs !== void 0 && config.maxAgeMs > 0) {
|
|
54
|
-
const cutoff = Date.now() - config.maxAgeMs;
|
|
55
|
-
events = events.filter((e) => {
|
|
56
|
-
const ts = new Date(e.timestamp).getTime();
|
|
57
|
-
return !Number.isNaN(ts) && ts >= cutoff;
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
const maxN = config.maxEventsPerIdentity ?? 0;
|
|
61
|
-
if (maxN > 0 && events.length > maxN) {
|
|
62
|
-
events = events.slice(events.length - maxN);
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
65
|
-
...doc,
|
|
66
|
-
events,
|
|
67
|
-
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
34
|
+
event_type: e.type,
|
|
35
|
+
event: e.event,
|
|
36
|
+
distinct_id: e.distinctId,
|
|
37
|
+
anonymous_id: e.anonymousId,
|
|
38
|
+
ts: e.timestamp,
|
|
39
|
+
message_id: e.messageId,
|
|
40
|
+
properties: JSON.stringify(e.properties),
|
|
41
|
+
context: JSON.stringify(e.context),
|
|
42
|
+
dt: e.timestamp.slice(0, 10)
|
|
68
43
|
};
|
|
69
44
|
}
|
|
70
45
|
|
|
71
46
|
// src/StarfishAnalyticsAdapter.ts
|
|
72
|
-
var
|
|
73
|
-
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
74
|
-
var GEN_KEY_PREFIX = "sg:starfish:gen:";
|
|
47
|
+
var DEFAULT_PATH_TEMPLATE = "events/{app}/{batchId}";
|
|
75
48
|
var StarfishAnalyticsAdapter = class {
|
|
76
49
|
constructor(config) {
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
79
|
-
this.
|
|
80
|
-
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
81
|
-
this.timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
82
|
-
this.pushOnly = config.pushOnly ?? false;
|
|
83
|
-
this.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
|
|
84
|
-
this.pathStorage = config.pathStorage ?? null;
|
|
85
|
-
if (this.rotatePathOnSuccess && !this.pathStorage) {
|
|
86
|
-
console.warn(
|
|
87
|
-
"[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
async send(batch) {
|
|
92
|
-
if (batch.length === 0) return;
|
|
93
|
-
const identity = batch[0].distinctId || batch[0].anonymousId;
|
|
94
|
-
if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
|
|
95
|
-
console.warn(
|
|
96
|
-
"[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
const baseResolved = resolveStoragePath(this.storagePath, identity);
|
|
100
|
-
if (this.pushOnly) {
|
|
101
|
-
return this.sendPushOnly(batch, baseResolved);
|
|
102
|
-
}
|
|
103
|
-
if (this.rotatePathOnSuccess && this.pathStorage) {
|
|
104
|
-
return this.sendRotating(batch, identity, baseResolved);
|
|
105
|
-
}
|
|
106
|
-
return this.sendMerge(batch, baseResolved);
|
|
107
|
-
}
|
|
108
|
-
async reset() {
|
|
50
|
+
this.client = config.client;
|
|
51
|
+
this.app = config.app;
|
|
52
|
+
this.pathTemplate = config.pathTemplate ?? DEFAULT_PATH_TEMPLATE;
|
|
109
53
|
}
|
|
110
|
-
|
|
54
|
+
resolvePath(batchId) {
|
|
55
|
+
return this.pathTemplate.replace("{app}", encodeURIComponent(this.app)).replace("{batchId}", batchId);
|
|
111
56
|
}
|
|
112
57
|
/**
|
|
113
|
-
*
|
|
114
|
-
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
115
|
-
* Only applicable in standard (non-rotating) mode.
|
|
116
|
-
*/
|
|
117
|
-
async cleanupAfterFlush(delivered, config) {
|
|
118
|
-
if (delivered.length === 0) return;
|
|
119
|
-
if (this.rotatePathOnSuccess) return;
|
|
120
|
-
const identity = delivered[0].distinctId || delivered[0].anonymousId;
|
|
121
|
-
const path = resolveStoragePath(this.storagePath, identity);
|
|
122
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
123
|
-
try {
|
|
124
|
-
const pulled = await this.pull(path);
|
|
125
|
-
if (!pulled) return;
|
|
126
|
-
const pruned = pruneDocument(pulled.data, config);
|
|
127
|
-
const pushResponse = await this.push(path, pruned, pulled.hash);
|
|
128
|
-
if (pushResponse.status === 409) {
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
return;
|
|
132
|
-
} catch {
|
|
133
|
-
if (attempt === this.maxRetries - 1) {
|
|
134
|
-
console.warn(
|
|
135
|
-
"[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// ── Private: standard pull-merge-push ──────────────────────────────────────
|
|
142
|
-
async sendMerge(batch, path) {
|
|
143
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
144
|
-
try {
|
|
145
|
-
const pulled = await this.pull(path);
|
|
146
|
-
const currentDoc = pulled?.data ?? createEmptyDocument();
|
|
147
|
-
const baseHash = pulled?.hash ?? "";
|
|
148
|
-
const updatedDoc = mergeEvents(currentDoc, batch);
|
|
149
|
-
const pushResponse = await this.push(path, updatedDoc, baseHash);
|
|
150
|
-
if (pushResponse.status === 409) {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (!pushResponse.ok) {
|
|
154
|
-
console.warn(
|
|
155
|
-
`[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
return;
|
|
159
|
-
} catch (err) {
|
|
160
|
-
const isLastAttempt = attempt === this.maxRetries - 1;
|
|
161
|
-
if (isLastAttempt) {
|
|
162
|
-
console.warn(
|
|
163
|
-
`[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
|
|
164
|
-
err
|
|
165
|
-
);
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
console.warn(
|
|
171
|
-
`[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
// ── Private: push-only mode (queueOnly collections) ──────────────────────
|
|
175
|
-
/**
|
|
176
|
-
* Push a fresh document directly, no pull, no merge, no conflict detection.
|
|
177
|
-
* Throws on failure so SunglassesCore keeps events in queue for retry.
|
|
178
|
-
*/
|
|
179
|
-
async sendPushOnly(batch, path) {
|
|
180
|
-
const doc = mergeEvents(createEmptyDocument(), batch);
|
|
181
|
-
const response = await this.push(path, doc, "");
|
|
182
|
-
if (!response.ok) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
`[SunGlasses] StarfishAnalyticsAdapter: push-only push failed with status ${response.status} for path "${path}"`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
// ── Private: rotating path mode ────────────────────────────────────────────
|
|
189
|
-
/**
|
|
190
|
-
* Push events to a new Starfish document each time.
|
|
58
|
+
* Push a batch of events to the Starfish events collection.
|
|
191
59
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
60
|
+
* A unique `batchId` is generated per call, producing a unique storage path
|
|
61
|
+
* (one Parquet file per flush). `baseHash: null` signals "must not exist" to
|
|
62
|
+
* Starfish, ensuring no conflict on the unique path.
|
|
194
63
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
64
|
+
* Throws on failure — SunglassesCore keeps the batch in the local queue.
|
|
65
|
+
* Never logs event contents (distinct_id, properties, context).
|
|
197
66
|
*/
|
|
198
|
-
async
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (response.ok) {
|
|
205
|
-
await this.saveGeneration(identity, gen + 1);
|
|
206
|
-
} else if (response.status === 409) {
|
|
207
|
-
await this.saveGeneration(identity, gen + 1);
|
|
208
|
-
throw new Error(
|
|
209
|
-
`[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
|
|
210
|
-
);
|
|
211
|
-
} else {
|
|
212
|
-
console.warn(
|
|
213
|
-
`[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
} catch (err) {
|
|
217
|
-
console.warn(
|
|
218
|
-
`[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
|
|
219
|
-
err
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
async loadGeneration(identity) {
|
|
224
|
-
if (!this.pathStorage) return 0;
|
|
225
|
-
try {
|
|
226
|
-
const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
|
|
227
|
-
return raw !== null ? parseInt(raw, 10) : 0;
|
|
228
|
-
} catch {
|
|
229
|
-
return 0;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
async saveGeneration(identity, gen) {
|
|
233
|
-
if (!this.pathStorage) return;
|
|
234
|
-
try {
|
|
235
|
-
await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
|
|
236
|
-
} catch (err) {
|
|
237
|
-
console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
// ── Private: HTTP helpers ──────────────────────────────────────────────────
|
|
241
|
-
async pull(path) {
|
|
242
|
-
const controller = new AbortController();
|
|
243
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
244
|
-
try {
|
|
245
|
-
const response = await fetch(`${this.serverUrl}/pull/${path}`, {
|
|
246
|
-
method: "GET",
|
|
247
|
-
headers: this.buildHeaders(),
|
|
248
|
-
signal: controller.signal
|
|
249
|
-
});
|
|
250
|
-
if (response.status === 404) {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
if (!response.ok) {
|
|
254
|
-
throw new Error(`Starfish pull failed: ${response.status}`);
|
|
255
|
-
}
|
|
256
|
-
return response.json();
|
|
257
|
-
} finally {
|
|
258
|
-
clearTimeout(timeoutId);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
async push(path, doc, baseHash) {
|
|
262
|
-
const controller = new AbortController();
|
|
263
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
264
|
-
try {
|
|
265
|
-
return await fetch(`${this.serverUrl}/push/${path}`, {
|
|
266
|
-
method: "POST",
|
|
267
|
-
headers: {
|
|
268
|
-
"Content-Type": "application/json",
|
|
269
|
-
...this.buildHeaders()
|
|
270
|
-
},
|
|
271
|
-
body: JSON.stringify({
|
|
272
|
-
data: doc,
|
|
273
|
-
baseHash: baseHash || void 0
|
|
274
|
-
}),
|
|
275
|
-
signal: controller.signal
|
|
276
|
-
});
|
|
277
|
-
} finally {
|
|
278
|
-
clearTimeout(timeoutId);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
buildHeaders() {
|
|
282
|
-
const headers = {};
|
|
283
|
-
if (this.authToken) {
|
|
284
|
-
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
285
|
-
}
|
|
286
|
-
return headers;
|
|
67
|
+
async send(batch) {
|
|
68
|
+
if (batch.length === 0) return;
|
|
69
|
+
const batchId = (0, import_sunglasses_core.generateUUID)();
|
|
70
|
+
const path = this.resolvePath(batchId);
|
|
71
|
+
const rows = batch.map(toStarfishRow);
|
|
72
|
+
await this.client.push(path, { events: rows }, null);
|
|
287
73
|
}
|
|
288
74
|
};
|
|
289
75
|
// Annotate the CommonJS export names for ESM import in node:
|
|
290
76
|
0 && (module.exports = {
|
|
291
77
|
StarfishAnalyticsAdapter,
|
|
292
|
-
|
|
293
|
-
mergeEvents,
|
|
294
|
-
pruneDocument,
|
|
295
|
-
resolveStoragePath
|
|
78
|
+
toStarfishRow
|
|
296
79
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -1,265 +1,51 @@
|
|
|
1
|
+
// src/StarfishAnalyticsAdapter.ts
|
|
2
|
+
import { generateUUID } from "@drakkar.software/sunglasses-core";
|
|
3
|
+
|
|
1
4
|
// src/StarfishEventMapper.ts
|
|
2
|
-
function
|
|
3
|
-
return {
|
|
4
|
-
events: [],
|
|
5
|
-
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6
|
-
version: "1"
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
function mergeEvents(remote, incoming) {
|
|
10
|
-
const existingIds = new Set(remote.events.map((e) => e.messageId));
|
|
11
|
-
const newEvents = incoming.filter((e) => !existingIds.has(e.messageId));
|
|
5
|
+
function toStarfishRow(e) {
|
|
12
6
|
return {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
let events = [...doc.events];
|
|
23
|
-
if (config.maxAgeMs !== void 0 && config.maxAgeMs > 0) {
|
|
24
|
-
const cutoff = Date.now() - config.maxAgeMs;
|
|
25
|
-
events = events.filter((e) => {
|
|
26
|
-
const ts = new Date(e.timestamp).getTime();
|
|
27
|
-
return !Number.isNaN(ts) && ts >= cutoff;
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
const maxN = config.maxEventsPerIdentity ?? 0;
|
|
31
|
-
if (maxN > 0 && events.length > maxN) {
|
|
32
|
-
events = events.slice(events.length - maxN);
|
|
33
|
-
}
|
|
34
|
-
return {
|
|
35
|
-
...doc,
|
|
36
|
-
events,
|
|
37
|
-
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
7
|
+
event_type: e.type,
|
|
8
|
+
event: e.event,
|
|
9
|
+
distinct_id: e.distinctId,
|
|
10
|
+
anonymous_id: e.anonymousId,
|
|
11
|
+
ts: e.timestamp,
|
|
12
|
+
message_id: e.messageId,
|
|
13
|
+
properties: JSON.stringify(e.properties),
|
|
14
|
+
context: JSON.stringify(e.context),
|
|
15
|
+
dt: e.timestamp.slice(0, 10)
|
|
38
16
|
};
|
|
39
17
|
}
|
|
40
18
|
|
|
41
19
|
// src/StarfishAnalyticsAdapter.ts
|
|
42
|
-
var
|
|
43
|
-
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
44
|
-
var GEN_KEY_PREFIX = "sg:starfish:gen:";
|
|
20
|
+
var DEFAULT_PATH_TEMPLATE = "events/{app}/{batchId}";
|
|
45
21
|
var StarfishAnalyticsAdapter = class {
|
|
46
22
|
constructor(config) {
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
51
|
-
this.timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
52
|
-
this.pushOnly = config.pushOnly ?? false;
|
|
53
|
-
this.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
|
|
54
|
-
this.pathStorage = config.pathStorage ?? null;
|
|
55
|
-
if (this.rotatePathOnSuccess && !this.pathStorage) {
|
|
56
|
-
console.warn(
|
|
57
|
-
"[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
async send(batch) {
|
|
62
|
-
if (batch.length === 0) return;
|
|
63
|
-
const identity = batch[0].distinctId || batch[0].anonymousId;
|
|
64
|
-
if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
|
|
65
|
-
console.warn(
|
|
66
|
-
"[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
const baseResolved = resolveStoragePath(this.storagePath, identity);
|
|
70
|
-
if (this.pushOnly) {
|
|
71
|
-
return this.sendPushOnly(batch, baseResolved);
|
|
72
|
-
}
|
|
73
|
-
if (this.rotatePathOnSuccess && this.pathStorage) {
|
|
74
|
-
return this.sendRotating(batch, identity, baseResolved);
|
|
75
|
-
}
|
|
76
|
-
return this.sendMerge(batch, baseResolved);
|
|
77
|
-
}
|
|
78
|
-
async reset() {
|
|
23
|
+
this.client = config.client;
|
|
24
|
+
this.app = config.app;
|
|
25
|
+
this.pathTemplate = config.pathTemplate ?? DEFAULT_PATH_TEMPLATE;
|
|
79
26
|
}
|
|
80
|
-
|
|
27
|
+
resolvePath(batchId) {
|
|
28
|
+
return this.pathTemplate.replace("{app}", encodeURIComponent(this.app)).replace("{batchId}", batchId);
|
|
81
29
|
}
|
|
82
30
|
/**
|
|
83
|
-
*
|
|
84
|
-
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
85
|
-
* Only applicable in standard (non-rotating) mode.
|
|
86
|
-
*/
|
|
87
|
-
async cleanupAfterFlush(delivered, config) {
|
|
88
|
-
if (delivered.length === 0) return;
|
|
89
|
-
if (this.rotatePathOnSuccess) return;
|
|
90
|
-
const identity = delivered[0].distinctId || delivered[0].anonymousId;
|
|
91
|
-
const path = resolveStoragePath(this.storagePath, identity);
|
|
92
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
93
|
-
try {
|
|
94
|
-
const pulled = await this.pull(path);
|
|
95
|
-
if (!pulled) return;
|
|
96
|
-
const pruned = pruneDocument(pulled.data, config);
|
|
97
|
-
const pushResponse = await this.push(path, pruned, pulled.hash);
|
|
98
|
-
if (pushResponse.status === 409) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
return;
|
|
102
|
-
} catch {
|
|
103
|
-
if (attempt === this.maxRetries - 1) {
|
|
104
|
-
console.warn(
|
|
105
|
-
"[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// ── Private: standard pull-merge-push ──────────────────────────────────────
|
|
112
|
-
async sendMerge(batch, path) {
|
|
113
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
114
|
-
try {
|
|
115
|
-
const pulled = await this.pull(path);
|
|
116
|
-
const currentDoc = pulled?.data ?? createEmptyDocument();
|
|
117
|
-
const baseHash = pulled?.hash ?? "";
|
|
118
|
-
const updatedDoc = mergeEvents(currentDoc, batch);
|
|
119
|
-
const pushResponse = await this.push(path, updatedDoc, baseHash);
|
|
120
|
-
if (pushResponse.status === 409) {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (!pushResponse.ok) {
|
|
124
|
-
console.warn(
|
|
125
|
-
`[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
return;
|
|
129
|
-
} catch (err) {
|
|
130
|
-
const isLastAttempt = attempt === this.maxRetries - 1;
|
|
131
|
-
if (isLastAttempt) {
|
|
132
|
-
console.warn(
|
|
133
|
-
`[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
|
|
134
|
-
err
|
|
135
|
-
);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
console.warn(
|
|
141
|
-
`[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
// ── Private: push-only mode (queueOnly collections) ──────────────────────
|
|
145
|
-
/**
|
|
146
|
-
* Push a fresh document directly, no pull, no merge, no conflict detection.
|
|
147
|
-
* Throws on failure so SunglassesCore keeps events in queue for retry.
|
|
148
|
-
*/
|
|
149
|
-
async sendPushOnly(batch, path) {
|
|
150
|
-
const doc = mergeEvents(createEmptyDocument(), batch);
|
|
151
|
-
const response = await this.push(path, doc, "");
|
|
152
|
-
if (!response.ok) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
`[SunGlasses] StarfishAnalyticsAdapter: push-only push failed with status ${response.status} for path "${path}"`
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
// ── Private: rotating path mode ────────────────────────────────────────────
|
|
159
|
-
/**
|
|
160
|
-
* Push events to a new Starfish document each time.
|
|
31
|
+
* Push a batch of events to the Starfish events collection.
|
|
161
32
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
33
|
+
* A unique `batchId` is generated per call, producing a unique storage path
|
|
34
|
+
* (one Parquet file per flush). `baseHash: null` signals "must not exist" to
|
|
35
|
+
* Starfish, ensuring no conflict on the unique path.
|
|
164
36
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
37
|
+
* Throws on failure — SunglassesCore keeps the batch in the local queue.
|
|
38
|
+
* Never logs event contents (distinct_id, properties, context).
|
|
167
39
|
*/
|
|
168
|
-
async
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (response.ok) {
|
|
175
|
-
await this.saveGeneration(identity, gen + 1);
|
|
176
|
-
} else if (response.status === 409) {
|
|
177
|
-
await this.saveGeneration(identity, gen + 1);
|
|
178
|
-
throw new Error(
|
|
179
|
-
`[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
|
|
180
|
-
);
|
|
181
|
-
} else {
|
|
182
|
-
console.warn(
|
|
183
|
-
`[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
} catch (err) {
|
|
187
|
-
console.warn(
|
|
188
|
-
`[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
|
|
189
|
-
err
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
async loadGeneration(identity) {
|
|
194
|
-
if (!this.pathStorage) return 0;
|
|
195
|
-
try {
|
|
196
|
-
const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
|
|
197
|
-
return raw !== null ? parseInt(raw, 10) : 0;
|
|
198
|
-
} catch {
|
|
199
|
-
return 0;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
async saveGeneration(identity, gen) {
|
|
203
|
-
if (!this.pathStorage) return;
|
|
204
|
-
try {
|
|
205
|
-
await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
|
|
206
|
-
} catch (err) {
|
|
207
|
-
console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
// ── Private: HTTP helpers ──────────────────────────────────────────────────
|
|
211
|
-
async pull(path) {
|
|
212
|
-
const controller = new AbortController();
|
|
213
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
214
|
-
try {
|
|
215
|
-
const response = await fetch(`${this.serverUrl}/pull/${path}`, {
|
|
216
|
-
method: "GET",
|
|
217
|
-
headers: this.buildHeaders(),
|
|
218
|
-
signal: controller.signal
|
|
219
|
-
});
|
|
220
|
-
if (response.status === 404) {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
if (!response.ok) {
|
|
224
|
-
throw new Error(`Starfish pull failed: ${response.status}`);
|
|
225
|
-
}
|
|
226
|
-
return response.json();
|
|
227
|
-
} finally {
|
|
228
|
-
clearTimeout(timeoutId);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
async push(path, doc, baseHash) {
|
|
232
|
-
const controller = new AbortController();
|
|
233
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
234
|
-
try {
|
|
235
|
-
return await fetch(`${this.serverUrl}/push/${path}`, {
|
|
236
|
-
method: "POST",
|
|
237
|
-
headers: {
|
|
238
|
-
"Content-Type": "application/json",
|
|
239
|
-
...this.buildHeaders()
|
|
240
|
-
},
|
|
241
|
-
body: JSON.stringify({
|
|
242
|
-
data: doc,
|
|
243
|
-
baseHash: baseHash || void 0
|
|
244
|
-
}),
|
|
245
|
-
signal: controller.signal
|
|
246
|
-
});
|
|
247
|
-
} finally {
|
|
248
|
-
clearTimeout(timeoutId);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
buildHeaders() {
|
|
252
|
-
const headers = {};
|
|
253
|
-
if (this.authToken) {
|
|
254
|
-
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
255
|
-
}
|
|
256
|
-
return headers;
|
|
40
|
+
async send(batch) {
|
|
41
|
+
if (batch.length === 0) return;
|
|
42
|
+
const batchId = generateUUID();
|
|
43
|
+
const path = this.resolvePath(batchId);
|
|
44
|
+
const rows = batch.map(toStarfishRow);
|
|
45
|
+
await this.client.push(path, { events: rows }, null);
|
|
257
46
|
}
|
|
258
47
|
};
|
|
259
48
|
export {
|
|
260
49
|
StarfishAnalyticsAdapter,
|
|
261
|
-
|
|
262
|
-
mergeEvents,
|
|
263
|
-
pruneDocument,
|
|
264
|
-
resolveStoragePath
|
|
50
|
+
toStarfishRow
|
|
265
51
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakkar.software/sunglasses-adapter-starfish",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Starfish
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Starfish adapter for SunGlasses — pushes event batches as JSON to a Starfish events collection (server encodes to Parquet)",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@drakkar.software/sunglasses-core": "0.
|
|
19
|
+
"@drakkar.software/sunglasses-core": "0.8.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"tsup": "^8.3.5",
|