node-avo-inspector 1.1.0 → 1.2.0-alpha.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/.claude/scheduled_tasks.lock +1 -0
- package/.claude/settings.local.json +11 -0
- package/.claude/worktrees/release-1.1.1/LICENSE +21 -0
- package/.claude/worktrees/release-1.1.1/README.md +69 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-js/index.js +30 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-js/package.json +15 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-js/yarn.lock +6 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-ts/dist/index.js +34 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-ts/index.ts +30 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-ts/package.json +18 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-ts/tsconfig.json +15 -0
- package/.claude/worktrees/release-1.1.1/examples/node-inspector-ts/yarn.lock +11 -0
- package/.claude/worktrees/release-1.1.1/package-lock.json +4039 -0
- package/.claude/worktrees/release-1.1.1/package.json +48 -0
- package/.claude/worktrees/release-1.1.1/reason/AvoInspector.rei +11 -0
- package/.claude/worktrees/release-1.1.1/reason/NodeAvoInspector.re +27 -0
- package/.claude/worktrees/release-1.1.1/src/AvoDeduplicator.ts +146 -0
- package/.claude/worktrees/release-1.1.1/src/AvoEncryption.ts +90 -0
- package/.claude/worktrees/release-1.1.1/src/AvoGuid.ts +9 -0
- package/.claude/worktrees/release-1.1.1/src/AvoInspector.ts +383 -0
- package/.claude/worktrees/release-1.1.1/src/AvoInspectorEnv.ts +8 -0
- package/.claude/worktrees/release-1.1.1/src/AvoNetworkCallsHandler.ts +267 -0
- package/.claude/worktrees/release-1.1.1/src/AvoSchemaParser.ts +115 -0
- package/.claude/worktrees/release-1.1.1/src/AvoStreamId.ts +16 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestAvoEncryption.ts +515 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestAvoEventSpecCache.ts +242 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestAvoEventSpecFetcher.ts +241 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestAvoInspector.ts +360 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestAvoStreamId.ts +72 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestDeduplicator.ts +191 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestEventValidator.ts +231 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestNetworkCallsHandler.ts +179 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestNetworkCallsHandlerPublic.ts +208 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/TestParsing.ts +153 -0
- package/.claude/worktrees/release-1.1.1/src/__tests__/constants.ts +63 -0
- package/.claude/worktrees/release-1.1.1/src/eventSpec/AvoEventSpecCache.ts +109 -0
- package/.claude/worktrees/release-1.1.1/src/eventSpec/AvoEventSpecFetchTypes.ts +47 -0
- package/.claude/worktrees/release-1.1.1/src/eventSpec/AvoEventSpecFetcher.ts +106 -0
- package/.claude/worktrees/release-1.1.1/src/eventSpec/EventValidator.ts +74 -0
- package/.claude/worktrees/release-1.1.1/src/index.ts +2 -0
- package/.claude/worktrees/release-1.1.1/src/utils.ts +49 -0
- package/.claude/worktrees/release-1.1.1/tsconfig.json +13 -0
- package/.claude/worktrees/release-1.1.1/yarn.lock +2310 -0
- package/.github/workflows/claude.yml +55 -0
- package/dist/AvoDeduplicator.d.ts +4 -3
- package/dist/AvoDeduplicator.js +25 -24
- package/dist/AvoEncryption.d.ts +26 -0
- package/dist/AvoEncryption.js +77 -0
- package/dist/AvoInspector.d.ts +34 -3
- package/dist/AvoInspector.js +196 -40
- package/dist/AvoNetworkCallsHandler.d.ts +39 -13
- package/dist/AvoNetworkCallsHandler.js +127 -33
- package/dist/AvoStreamId.d.ts +5 -0
- package/dist/AvoStreamId.js +15 -0
- package/dist/__tests__/TestAvoEncryption.d.ts +1 -0
- package/dist/__tests__/TestAvoEncryption.js +288 -0
- package/dist/__tests__/TestAvoEventSpecCache.d.ts +1 -0
- package/dist/__tests__/TestAvoEventSpecCache.js +194 -0
- package/dist/__tests__/TestAvoEventSpecFetcher.d.ts +1 -0
- package/dist/__tests__/TestAvoEventSpecFetcher.js +219 -0
- package/dist/__tests__/TestAvoInspector.js +76 -2
- package/dist/__tests__/TestAvoStreamId.d.ts +1 -0
- package/dist/__tests__/TestAvoStreamId.js +55 -0
- package/dist/__tests__/TestDeduplicator.js +3 -0
- package/dist/__tests__/TestEventValidator.d.ts +1 -0
- package/dist/__tests__/TestEventValidator.js +196 -0
- package/dist/__tests__/TestNetworkCallsHandler.js +37 -10
- package/dist/__tests__/TestNetworkCallsHandlerPublic.js +93 -58
- package/dist/eventSpec/AvoEventSpecCache.d.ts +19 -0
- package/dist/eventSpec/AvoEventSpecCache.js +100 -0
- package/dist/eventSpec/AvoEventSpecFetchTypes.d.ts +41 -0
- package/dist/eventSpec/AvoEventSpecFetchTypes.js +5 -0
- package/dist/eventSpec/AvoEventSpecFetcher.d.ts +25 -0
- package/dist/eventSpec/AvoEventSpecFetcher.js +159 -0
- package/dist/eventSpec/EventValidator.d.ts +15 -0
- package/dist/eventSpec/EventValidator.js +53 -0
- package/package.json +4 -2
- package/src/AvoDeduplicator.ts +31 -27
- package/src/AvoEncryption.ts +90 -0
- package/src/AvoInspector.ts +278 -54
- package/src/AvoNetworkCallsHandler.ts +205 -45
- package/src/AvoStreamId.ts +16 -0
- package/src/__tests__/TestAvoEncryption.ts +515 -0
- package/src/__tests__/TestAvoEventSpecCache.ts +242 -0
- package/src/__tests__/TestAvoEventSpecFetcher.ts +279 -0
- package/src/__tests__/TestAvoInspector.ts +114 -2
- package/src/__tests__/TestAvoStreamId.ts +72 -0
- package/src/__tests__/TestDeduplicator.ts +4 -0
- package/src/__tests__/TestEventValidator.ts +231 -0
- package/src/__tests__/TestNetworkCallsHandler.ts +82 -15
- package/src/__tests__/TestNetworkCallsHandlerPublic.ts +134 -63
- package/src/eventSpec/AvoEventSpecCache.ts +119 -0
- package/src/eventSpec/AvoEventSpecFetchTypes.ts +47 -0
- package/src/eventSpec/AvoEventSpecFetcher.ts +186 -0
- package/src/eventSpec/EventValidator.ts +74 -0
|
@@ -5,47 +5,52 @@ import { defaultOptions } from "./constants";
|
|
|
5
5
|
|
|
6
6
|
describe("AvoNetworkCallsHandler", () => {
|
|
7
7
|
let inspectorCallSpy: jest.SpyInstance<any, unknown[]>;
|
|
8
|
+
let consoleLogSpy: jest.SpyInstance<any, unknown[]>;
|
|
8
9
|
|
|
9
10
|
let inspector: AvoInspector;
|
|
10
11
|
|
|
11
12
|
const { apiKey, env, version, appName } = defaultOptions;
|
|
12
13
|
|
|
14
|
+
const eventName = "event name";
|
|
15
|
+
const properties = {
|
|
16
|
+
prop0: "",
|
|
17
|
+
prop2: false,
|
|
18
|
+
prop3: 0,
|
|
19
|
+
prop4: 0.0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const happyPathInspectorCall = () => {
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
};
|
|
25
|
+
|
|
13
26
|
beforeAll(() => {
|
|
14
27
|
inspector = new AvoInspector(defaultOptions);
|
|
15
|
-
inspector.enableLogging(
|
|
28
|
+
inspector.enableLogging(true);
|
|
16
29
|
|
|
17
30
|
inspectorCallSpy = jest
|
|
18
31
|
.spyOn(AvoNetworkCallsHandler.prototype as any, "callInspectorWithBatchBody");
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
inspector.avoNetworkCallsHandler["samplingRate"] = 0.1;
|
|
22
|
-
return Promise.resolve();
|
|
23
|
-
});
|
|
33
|
+
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
24
34
|
});
|
|
25
35
|
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
inspectorCallSpy.mockImplementation(happyPathInspectorCall);
|
|
38
|
+
})
|
|
39
|
+
|
|
26
40
|
afterEach(() => {
|
|
27
41
|
jest.clearAllMocks();
|
|
28
42
|
// @ts-ignore
|
|
29
43
|
inspector.avoDeduplicator._clearEvents();
|
|
30
44
|
});
|
|
31
45
|
|
|
32
|
-
test("
|
|
33
|
-
const eventName = "event name";
|
|
34
|
-
const properties = {
|
|
35
|
-
prop0: "",
|
|
36
|
-
prop2: false,
|
|
37
|
-
prop3: 0,
|
|
38
|
-
prop4: 0.0,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const schema = inspector.extractSchema(properties);
|
|
42
|
-
|
|
46
|
+
test("trackSchemaFromEvent sends only event call (no sessionStarted)", async () => {
|
|
43
47
|
await inspector.trackSchemaFromEvent(eventName, properties);
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).
|
|
49
|
+
// Should be called once (event only, no sessionStarted)
|
|
50
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
47
52
|
expect.objectContaining({
|
|
48
|
-
type: "
|
|
53
|
+
type: "event",
|
|
49
54
|
apiKey: apiKey,
|
|
50
55
|
appName: appName,
|
|
51
56
|
appVersion: version,
|
|
@@ -53,13 +58,62 @@ describe("AvoNetworkCallsHandler", () => {
|
|
|
53
58
|
env: env,
|
|
54
59
|
libPlatform: "node",
|
|
55
60
|
messageId: expect.anything(),
|
|
56
|
-
|
|
61
|
+
anonymousId: "",
|
|
57
62
|
createdAt: expect.any(String),
|
|
58
|
-
sessionId: expect.anything(),
|
|
59
63
|
samplingRate: 1,
|
|
60
64
|
}),
|
|
61
|
-
]
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("trackSchemaFromEvent with streamId sets anonymousId to streamId", async () => {
|
|
69
|
+
await inspector.trackSchemaFromEvent(eventName, properties, "user-stream-123");
|
|
70
|
+
|
|
71
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
type: "event",
|
|
75
|
+
anonymousId: "user-stream-123",
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("trackSchemaFromEvent without streamId sets anonymousId to empty string", async () => {
|
|
81
|
+
await inspector.trackSchemaFromEvent(eventName, properties);
|
|
82
|
+
|
|
83
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
anonymousId: "",
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("trackSchemaFromEvent with streamId containing ':' logs warning", async () => {
|
|
92
|
+
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
93
|
+
|
|
94
|
+
await inspector.trackSchemaFromEvent(eventName, properties, "invalid:stream");
|
|
95
|
+
|
|
96
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
97
|
+
"[Avo Inspector] Warning: streamId contains ':' which is not supported"
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
consoleWarnSpy.mockRestore();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("_avoFunctionTrackSchemaFromEvent sends only event call (no sessionStarted)", async () => {
|
|
104
|
+
const eventId = "testId";
|
|
105
|
+
const eventHash = "testHash";
|
|
106
|
+
|
|
107
|
+
// @ts-ignore
|
|
108
|
+
await inspector._avoFunctionTrackSchemaFromEvent(
|
|
109
|
+
eventName,
|
|
110
|
+
properties,
|
|
111
|
+
eventId,
|
|
112
|
+
eventHash
|
|
62
113
|
);
|
|
114
|
+
|
|
115
|
+
// Should be called once (event only, no sessionStarted)
|
|
116
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(1);
|
|
63
117
|
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
64
118
|
expect.objectContaining({
|
|
65
119
|
type: "event",
|
|
@@ -70,27 +124,26 @@ describe("AvoNetworkCallsHandler", () => {
|
|
|
70
124
|
env: env,
|
|
71
125
|
libPlatform: "node",
|
|
72
126
|
messageId: expect.anything(),
|
|
73
|
-
|
|
74
|
-
createdAt: expect.
|
|
75
|
-
sessionId: expect.anything(),
|
|
76
|
-
samplingRate: 0.1,
|
|
127
|
+
anonymousId: "",
|
|
128
|
+
createdAt: expect.any(String),
|
|
77
129
|
}),
|
|
78
130
|
]);
|
|
79
131
|
});
|
|
80
132
|
|
|
81
|
-
test("
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
133
|
+
test("an error is logged if event schema call fails and AvoInspector.shouldLog is true", async () => {
|
|
134
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
135
|
+
inspectorCallSpy.mockRejectedValueOnce('Network error');
|
|
136
|
+
|
|
137
|
+
await inspector.trackSchemaFromEvent(eventName, properties);
|
|
138
|
+
|
|
139
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('Avo Inspector: schema sending failed: Network error.');
|
|
140
|
+
consoleErrorSpy.mockRestore();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("_avoFunctionTrackSchemaFromEvent passes empty string as anonymousId", async () => {
|
|
89
144
|
const eventId = "testId";
|
|
90
145
|
const eventHash = "testHash";
|
|
91
146
|
|
|
92
|
-
const schema = inspector.extractSchema(properties);
|
|
93
|
-
|
|
94
147
|
// @ts-ignore
|
|
95
148
|
await inspector._avoFunctionTrackSchemaFromEvent(
|
|
96
149
|
eventName,
|
|
@@ -99,39 +152,57 @@ describe("AvoNetworkCallsHandler", () => {
|
|
|
99
152
|
eventHash
|
|
100
153
|
);
|
|
101
154
|
|
|
102
|
-
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).
|
|
103
|
-
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toBeCalledWith([
|
|
155
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
104
156
|
expect.objectContaining({
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
]
|
|
157
|
+
anonymousId: "",
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("trackSchemaFromEvent with streamId containing ':' still sends the event", async () => {
|
|
163
|
+
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
164
|
+
|
|
165
|
+
await inspector.trackSchemaFromEvent(eventName, properties, "bad:stream");
|
|
166
|
+
|
|
167
|
+
// Warning is logged
|
|
168
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
169
|
+
"[Avo Inspector] Warning: streamId contains ':' which is not supported"
|
|
119
170
|
);
|
|
171
|
+
|
|
172
|
+
// Event is still sent despite the warning
|
|
173
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(1);
|
|
120
174
|
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledWith([
|
|
121
175
|
expect.objectContaining({
|
|
122
176
|
type: "event",
|
|
123
|
-
|
|
124
|
-
appName: appName,
|
|
125
|
-
appVersion: version,
|
|
126
|
-
libVersion: expect.anything(),
|
|
127
|
-
env: env,
|
|
128
|
-
libPlatform: "node",
|
|
129
|
-
messageId: expect.anything(),
|
|
130
|
-
trackingId: "",
|
|
131
|
-
createdAt: expect.anything(),
|
|
132
|
-
sessionId: expect.anything(),
|
|
133
|
-
samplingRate: 0.1,
|
|
177
|
+
anonymousId: "bad:stream",
|
|
134
178
|
}),
|
|
135
179
|
]);
|
|
180
|
+
|
|
181
|
+
consoleWarnSpy.mockRestore();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("same event with different streamIds is not deduplicated", async () => {
|
|
185
|
+
await inspector.trackSchemaFromEvent(eventName, properties, "stream-a");
|
|
186
|
+
await inspector.trackSchemaFromEvent(eventName, properties, "stream-b");
|
|
187
|
+
|
|
188
|
+
expect(inspector.avoNetworkCallsHandler.callInspectorWithBatchBody).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("trackSchemaFromEvent rejection contains the expected error string", async () => {
|
|
192
|
+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
// Force the catch block in trackSchemaFromEvent by making extractSchema throw
|
|
195
|
+
jest.spyOn(inspector, "extractSchema").mockImplementation(() => {
|
|
196
|
+
throw new Error("test explosion");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
inspector.trackSchemaFromEvent(eventName, properties)
|
|
201
|
+
).rejects.toBe(
|
|
202
|
+
"Avo Inspector: something went wrong. Please report to support@avo.app."
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
consoleErrorSpy.mockRestore();
|
|
206
|
+
(inspector.extractSchema as jest.Mock).mockRestore();
|
|
136
207
|
});
|
|
137
208
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { EventSpecResponse } from "./AvoEventSpecFetchTypes";
|
|
2
|
+
|
|
3
|
+
interface CacheEntry {
|
|
4
|
+
value: EventSpecResponse;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
eventCount: number;
|
|
7
|
+
lastAccessed: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_EVENT_COUNT = 50;
|
|
11
|
+
const TTL_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
export class AvoEventSpecCache {
|
|
14
|
+
private cache: Map<string, CacheEntry> = new Map();
|
|
15
|
+
private globalEventCount = 0;
|
|
16
|
+
|
|
17
|
+
static makeKey(apiKey: string, streamId: string, eventName: string): string {
|
|
18
|
+
return `${apiKey}\0${streamId}\0${eventName}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the cached EventSpecResponse, or undefined on cache miss.
|
|
23
|
+
* A cached response with eventSpec: null means "no spec exists for this event".
|
|
24
|
+
*/
|
|
25
|
+
get(key: string): EventSpecResponse | undefined {
|
|
26
|
+
const entry = this.cache.get(key);
|
|
27
|
+
if (entry === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// TTL check
|
|
32
|
+
if (Date.now() - entry.timestamp > TTL_MS) {
|
|
33
|
+
this.cache.delete(key);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Per-entry access count eviction
|
|
38
|
+
entry.eventCount++;
|
|
39
|
+
if (entry.eventCount >= MAX_EVENT_COUNT) {
|
|
40
|
+
this.cache.delete(key);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
entry.lastAccessed = Date.now();
|
|
45
|
+
return entry.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true if the key exists in the cache and has not expired.
|
|
50
|
+
*/
|
|
51
|
+
contains(key: string): boolean {
|
|
52
|
+
const entry = this.cache.get(key);
|
|
53
|
+
if (entry === undefined) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (Date.now() - entry.timestamp > TTL_MS) {
|
|
57
|
+
this.cache.delete(key);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (entry.eventCount >= MAX_EVENT_COUNT) {
|
|
61
|
+
this.cache.delete(key);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set(key: string, value: EventSpecResponse): void {
|
|
68
|
+
this.globalEventCount++;
|
|
69
|
+
|
|
70
|
+
// Global rotation: evict LRU entry every MAX_EVENT_COUNT operations
|
|
71
|
+
if (this.globalEventCount >= MAX_EVENT_COUNT) {
|
|
72
|
+
this.purgeExpired();
|
|
73
|
+
this.evictLRU();
|
|
74
|
+
this.globalEventCount = 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.cache.set(key, {
|
|
78
|
+
value,
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
eventCount: 0,
|
|
81
|
+
lastAccessed: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Size cap: evict oldest entries when exceeding max
|
|
85
|
+
while (this.cache.size > MAX_EVENT_COUNT) {
|
|
86
|
+
this.evictLRU();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
flush(): void {
|
|
91
|
+
this.cache.clear();
|
|
92
|
+
this.globalEventCount = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private purgeExpired(): void {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
98
|
+
if (now - entry.timestamp > TTL_MS) {
|
|
99
|
+
this.cache.delete(key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private evictLRU(): void {
|
|
105
|
+
let oldestKey: string | null = null;
|
|
106
|
+
let oldestTime = Infinity;
|
|
107
|
+
|
|
108
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
109
|
+
if (entry.lastAccessed < oldestTime) {
|
|
110
|
+
oldestTime = entry.lastAccessed;
|
|
111
|
+
oldestKey = key;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (oldestKey !== null) {
|
|
116
|
+
this.cache.delete(oldestKey);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for Event Spec validation responses.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** A single property constraint from the spec. */
|
|
6
|
+
export interface PropertyConstraint {
|
|
7
|
+
propertyName: string;
|
|
8
|
+
/** Expected type, e.g. "string", "int", "float", "boolean", "object", "list(string)" */
|
|
9
|
+
propertyType: string;
|
|
10
|
+
/** Optional regex pattern that the value must match */
|
|
11
|
+
regex?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The spec for a single event, as returned by the API. */
|
|
15
|
+
export interface EventSpec {
|
|
16
|
+
eventName: string;
|
|
17
|
+
properties: PropertyConstraint[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Metadata identifying the spec source. */
|
|
21
|
+
export interface EventSpecMetadata {
|
|
22
|
+
schemaId: string;
|
|
23
|
+
branchId: string;
|
|
24
|
+
latestActionId: string;
|
|
25
|
+
sourceId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Full response from the spec endpoint. */
|
|
29
|
+
export interface EventSpecResponse {
|
|
30
|
+
eventSpec: EventSpec | null;
|
|
31
|
+
metadata: EventSpecMetadata;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-property validation result. */
|
|
35
|
+
export interface PropertyValidationResult {
|
|
36
|
+
propertyName: string;
|
|
37
|
+
failedEventIds: string[];
|
|
38
|
+
passedEventIds: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Payload sent to reportValidatedEvent. */
|
|
42
|
+
export interface ValidatedEventPayload {
|
|
43
|
+
streamId: string;
|
|
44
|
+
eventName: string;
|
|
45
|
+
eventSpecMetadata: EventSpecMetadata;
|
|
46
|
+
propertyResults: PropertyValidationResult[];
|
|
47
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { request, Agent } from "https";
|
|
2
|
+
import { EventSpecResponse, EventSpec, EventSpecMetadata, PropertyConstraint } from "./AvoEventSpecFetchTypes";
|
|
3
|
+
import { AvoInspector } from "../AvoInspector";
|
|
4
|
+
|
|
5
|
+
type FetchCallback = (result: EventSpecResponse | null) => void;
|
|
6
|
+
|
|
7
|
+
export class AvoEventSpecFetcher {
|
|
8
|
+
private apiKey: string;
|
|
9
|
+
private inFlight: Map<string, FetchCallback[]> = new Map();
|
|
10
|
+
private agent: Agent;
|
|
11
|
+
|
|
12
|
+
private static specEndpoint = "/trackingPlan/eventSpec";
|
|
13
|
+
|
|
14
|
+
constructor(apiKey: string) {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
this.agent = new Agent({ keepAlive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fetch(
|
|
20
|
+
eventName: string,
|
|
21
|
+
streamId: string,
|
|
22
|
+
callback: FetchCallback
|
|
23
|
+
): void {
|
|
24
|
+
const dedupeKey = `${this.apiKey}:${streamId}:${eventName}`;
|
|
25
|
+
|
|
26
|
+
// In-flight dedup: if there's already a request in flight for this key,
|
|
27
|
+
// queue the callback
|
|
28
|
+
const existingCallbacks = this.inFlight.get(dedupeKey);
|
|
29
|
+
if (existingCallbacks) {
|
|
30
|
+
existingCallbacks.push(callback);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Register the callback and start the request
|
|
35
|
+
this.inFlight.set(dedupeKey, [callback]);
|
|
36
|
+
|
|
37
|
+
const queryParams = new URLSearchParams({
|
|
38
|
+
apiKey: this.apiKey,
|
|
39
|
+
eventName,
|
|
40
|
+
streamId,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const options = {
|
|
44
|
+
hostname: "api.avo.app",
|
|
45
|
+
port: 443,
|
|
46
|
+
path: `${AvoEventSpecFetcher.specEndpoint}?${queryParams.toString()}`,
|
|
47
|
+
method: "GET",
|
|
48
|
+
agent: this.agent,
|
|
49
|
+
headers: {
|
|
50
|
+
Accept: "application/json",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (AvoInspector.shouldLog) {
|
|
55
|
+
console.log("Avo Inspector: [network] GET https://" + options.hostname + AvoEventSpecFetcher.specEndpoint + "?eventName=" + encodeURIComponent(eventName));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const req = request(options, (res) => {
|
|
59
|
+
if (AvoInspector.shouldLog) {
|
|
60
|
+
console.log("Avo Inspector: [network] Spec response status: " + res.statusCode + " " + res.statusMessage);
|
|
61
|
+
}
|
|
62
|
+
const chunks: Buffer[] = [];
|
|
63
|
+
res.on("data", (data: Buffer) => chunks.push(data));
|
|
64
|
+
res.on("end", () => {
|
|
65
|
+
let result: EventSpecResponse | null = null;
|
|
66
|
+
if (res.statusCode !== 200) {
|
|
67
|
+
if (AvoInspector.shouldLog) {
|
|
68
|
+
const body = Buffer.concat(chunks).toString();
|
|
69
|
+
console.warn("Avo Inspector: [network] Spec fetch failed with status " + res.statusCode + ": " + body);
|
|
70
|
+
}
|
|
71
|
+
this.resolveCallbacks(dedupeKey, null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const body = Buffer.concat(chunks).toString();
|
|
76
|
+
if (AvoInspector.shouldLog) {
|
|
77
|
+
console.log("Avo Inspector: [network] Spec response body: " + body);
|
|
78
|
+
}
|
|
79
|
+
const wire = JSON.parse(body);
|
|
80
|
+
result = AvoEventSpecFetcher.parseWireResponse(wire, eventName);
|
|
81
|
+
if (AvoInspector.shouldLog) {
|
|
82
|
+
console.log("Avo Inspector: [network] Parsed spec: " + JSON.stringify(result));
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (AvoInspector.shouldLog) {
|
|
86
|
+
console.warn("Avo Inspector: [network] Failed to parse spec response: " + e);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.resolveCallbacks(dedupeKey, result);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
req.on("error", (err) => {
|
|
94
|
+
if (AvoInspector.shouldLog) {
|
|
95
|
+
console.error("Avo Inspector: [network] Spec fetch error: " + err);
|
|
96
|
+
}
|
|
97
|
+
this.resolveCallbacks(dedupeKey, null);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
req.setTimeout(10_000);
|
|
101
|
+
req.on("timeout", () => {
|
|
102
|
+
if (AvoInspector.shouldLog) {
|
|
103
|
+
console.error("Avo Inspector: [network] Spec fetch timed out after 10s");
|
|
104
|
+
}
|
|
105
|
+
req.destroy();
|
|
106
|
+
this.resolveCallbacks(dedupeKey, null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
req.end();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private resolveCallbacks(
|
|
113
|
+
key: string,
|
|
114
|
+
result: EventSpecResponse | null
|
|
115
|
+
): void {
|
|
116
|
+
const callbacks = this.inFlight.get(key);
|
|
117
|
+
this.inFlight.delete(key);
|
|
118
|
+
|
|
119
|
+
if (callbacks) {
|
|
120
|
+
for (const cb of callbacks) {
|
|
121
|
+
try {
|
|
122
|
+
cb(result);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Don't let one callback failure affect others
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse the wire format from /trackingPlan/eventSpec into our internal types.
|
|
132
|
+
*
|
|
133
|
+
* Wire format:
|
|
134
|
+
* { events: [{ b, id, vids, p: { "PropName": { t: "string", r: true, v: {...}, rx: {...} } } }],
|
|
135
|
+
* metadata: { schemaId, branchId, latestActionId, sourceId } }
|
|
136
|
+
*
|
|
137
|
+
* Internal format:
|
|
138
|
+
* { eventSpec: { eventName, properties: [{ propertyName, propertyType, regex? }] } | null,
|
|
139
|
+
* metadata: { schemaId, branchId, latestActionId, sourceId } }
|
|
140
|
+
*/
|
|
141
|
+
static parseWireResponse(wire: any, eventName: string): EventSpecResponse {
|
|
142
|
+
const metadata: EventSpecMetadata = {
|
|
143
|
+
schemaId: wire.metadata?.schemaId ?? "",
|
|
144
|
+
branchId: wire.metadata?.branchId ?? "",
|
|
145
|
+
latestActionId: wire.metadata?.latestActionId ?? "",
|
|
146
|
+
sourceId: wire.metadata?.sourceId ?? "",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!wire.events || !Array.isArray(wire.events) || wire.events.length === 0) {
|
|
150
|
+
return { eventSpec: null, metadata };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Use the first event entry (the endpoint returns specs for the requested event)
|
|
154
|
+
const entry = wire.events[0];
|
|
155
|
+
const properties: PropertyConstraint[] = [];
|
|
156
|
+
|
|
157
|
+
if (entry.p && typeof entry.p === "object") {
|
|
158
|
+
for (const propName of Object.keys(entry.p)) {
|
|
159
|
+
const constraint = entry.p[propName];
|
|
160
|
+
const prop: PropertyConstraint = {
|
|
161
|
+
propertyName: propName,
|
|
162
|
+
propertyType: constraint?.t ?? "unknown",
|
|
163
|
+
};
|
|
164
|
+
// Extract regex pattern if present (rx field maps regex patterns to event IDs)
|
|
165
|
+
if (constraint?.rx && typeof constraint.rx === "object") {
|
|
166
|
+
const patterns = Object.keys(constraint.rx);
|
|
167
|
+
if (patterns.length > 0) {
|
|
168
|
+
prop.regex = patterns[0];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
properties.push(prop);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const eventSpec: EventSpec = {
|
|
176
|
+
eventName,
|
|
177
|
+
properties,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return { eventSpec, metadata };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
destroy(): void {
|
|
184
|
+
this.agent.destroy();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import safeRegex from "safe-regex2";
|
|
2
|
+
import {
|
|
3
|
+
EventSpec,
|
|
4
|
+
PropertyValidationResult,
|
|
5
|
+
} from "./AvoEventSpecFetchTypes";
|
|
6
|
+
|
|
7
|
+
interface EventProperty {
|
|
8
|
+
propertyName: string;
|
|
9
|
+
propertyType: string;
|
|
10
|
+
propertyValue?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class EventValidator {
|
|
14
|
+
/**
|
|
15
|
+
* Validates event properties against the spec.
|
|
16
|
+
* Returns per-property validation results with bandwidth optimization:
|
|
17
|
+
* passedEventIds is returned only when strictly smaller than failedEventIds.
|
|
18
|
+
*/
|
|
19
|
+
validate(
|
|
20
|
+
spec: EventSpec,
|
|
21
|
+
eventProperties: EventProperty[],
|
|
22
|
+
eventId: string
|
|
23
|
+
): PropertyValidationResult[] {
|
|
24
|
+
const results: PropertyValidationResult[] = [];
|
|
25
|
+
const eventPropMap = new Map<string, EventProperty>();
|
|
26
|
+
|
|
27
|
+
for (const prop of eventProperties) {
|
|
28
|
+
eventPropMap.set(prop.propertyName, prop);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const specProp of spec.properties) {
|
|
32
|
+
const actualProp = eventPropMap.get(specProp.propertyName);
|
|
33
|
+
let passed = false;
|
|
34
|
+
|
|
35
|
+
if (actualProp) {
|
|
36
|
+
// Type check
|
|
37
|
+
passed = actualProp.propertyType === specProp.propertyType;
|
|
38
|
+
|
|
39
|
+
// Regex check (only if type passed, regex is defined, and value is available)
|
|
40
|
+
if (passed && specProp.regex && actualProp.propertyValue !== undefined) {
|
|
41
|
+
if (!safeRegex(specProp.regex)) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[Avo Inspector] Warning: Unsafe regex pattern skipped for property "${specProp.propertyName}": ${specProp.regex}`
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
const re = new RegExp(specProp.regex);
|
|
48
|
+
passed = re.test(actualProp.propertyValue);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[Avo Inspector] Warning: Invalid regex pattern for property "${specProp.propertyName}": ${specProp.regex}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result: PropertyValidationResult = {
|
|
59
|
+
propertyName: specProp.propertyName,
|
|
60
|
+
failedEventIds: passed ? [] : [eventId],
|
|
61
|
+
passedEventIds: passed ? [eventId] : [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Bandwidth optimization: return passedEventIds only when strictly smaller than failedEventIds
|
|
65
|
+
if (result.passedEventIds.length >= result.failedEventIds.length) {
|
|
66
|
+
result.passedEventIds = [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
results.push(result);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
}
|