@xray-analytics/analytics-server 0.0.2
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 +177 -0
- package/dist/index.d.ts +177 -0
- package/dist/index.js +475 -0
- package/dist/index.mjs +440 -0
- package/package.json +35 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ZodType } from 'zod';
|
|
2
|
+
|
|
3
|
+
type AnalyticsEventProps = Record<string, unknown>;
|
|
4
|
+
type AnalyticsEventInput = {
|
|
5
|
+
name: string;
|
|
6
|
+
ts?: number;
|
|
7
|
+
appId: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
ref?: string;
|
|
12
|
+
environment?: string;
|
|
13
|
+
props?: AnalyticsEventProps;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
clientMeta?: {
|
|
16
|
+
ip?: string;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
isMobile?: boolean;
|
|
19
|
+
os?: string;
|
|
20
|
+
platform?: string;
|
|
21
|
+
language?: string;
|
|
22
|
+
screen?: {
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
writeKey?: string;
|
|
28
|
+
};
|
|
29
|
+
type IngestContext = {
|
|
30
|
+
ip?: string;
|
|
31
|
+
userAgent?: string;
|
|
32
|
+
headers?: Record<string, string | undefined>;
|
|
33
|
+
requestId?: string;
|
|
34
|
+
};
|
|
35
|
+
type StoredAnalyticsEvent = AnalyticsEventInput & {
|
|
36
|
+
ts: number;
|
|
37
|
+
receivedAt: number;
|
|
38
|
+
props: AnalyticsEventProps;
|
|
39
|
+
meta?: {
|
|
40
|
+
ip?: string;
|
|
41
|
+
userAgent?: string;
|
|
42
|
+
requestId?: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
interface AnalyticsStorageAdapter {
|
|
46
|
+
save: (event: StoredAnalyticsEvent) => Promise<void>;
|
|
47
|
+
getAll: (dateInit?: number, dateEnd?: number) => Promise<StoredAnalyticsEvent[]>;
|
|
48
|
+
clear: (dateInit?: number, dateEnd?: number) => Promise<number>;
|
|
49
|
+
}
|
|
50
|
+
type MaskFieldArgs = {
|
|
51
|
+
path: string;
|
|
52
|
+
key: string;
|
|
53
|
+
value: unknown;
|
|
54
|
+
event: StoredAnalyticsEvent;
|
|
55
|
+
};
|
|
56
|
+
type MaskConfig = {
|
|
57
|
+
paths?: string[];
|
|
58
|
+
keyPatterns?: RegExp[];
|
|
59
|
+
maskValue?: string;
|
|
60
|
+
maskField?: (args: MaskFieldArgs) => unknown;
|
|
61
|
+
};
|
|
62
|
+
type AcceptedTrack = {
|
|
63
|
+
trackName: string;
|
|
64
|
+
schema: ZodType;
|
|
65
|
+
validateOn?: 'props' | 'event';
|
|
66
|
+
version?: number;
|
|
67
|
+
description?: string;
|
|
68
|
+
tags?: string[];
|
|
69
|
+
deprecated?: boolean;
|
|
70
|
+
catalogSchema?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
type AnalyticsTrackCatalogItem = {
|
|
73
|
+
trackName: string;
|
|
74
|
+
validateOn: 'props' | 'event';
|
|
75
|
+
version: number;
|
|
76
|
+
description?: string;
|
|
77
|
+
tags?: string[];
|
|
78
|
+
deprecated?: boolean;
|
|
79
|
+
schema?: Record<string, unknown>;
|
|
80
|
+
};
|
|
81
|
+
type AnalyticsTrackCatalog = {
|
|
82
|
+
generatedAt: number;
|
|
83
|
+
tracks: AnalyticsTrackCatalogItem[];
|
|
84
|
+
};
|
|
85
|
+
type AnalyticsServerConfig = {
|
|
86
|
+
storage: AnalyticsStorageAdapter;
|
|
87
|
+
acceptedTracks?: AcceptedTrack[];
|
|
88
|
+
rejectUnknownTracks?: boolean;
|
|
89
|
+
masking?: MaskConfig;
|
|
90
|
+
};
|
|
91
|
+
type IngestErrorCode = 'invalid_payload' | 'track_not_allowed' | 'schema_mismatch' | 'storage_error';
|
|
92
|
+
type IngestResult = {
|
|
93
|
+
ok: true;
|
|
94
|
+
event: StoredAnalyticsEvent;
|
|
95
|
+
} | {
|
|
96
|
+
ok: false;
|
|
97
|
+
error: {
|
|
98
|
+
code: IngestErrorCode;
|
|
99
|
+
message: string;
|
|
100
|
+
details?: unknown;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
declare function createAnalyticsServer(config: AnalyticsServerConfig): {
|
|
105
|
+
ingest: (input: unknown, context?: IngestContext) => Promise<IngestResult>;
|
|
106
|
+
getCatalog: () => AnalyticsTrackCatalog;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
declare function createMemoryAdapter(initialEvents?: StoredAnalyticsEvent[]): AnalyticsStorageAdapter;
|
|
110
|
+
|
|
111
|
+
type PostgresQueryable = {
|
|
112
|
+
query: (text: string, values?: unknown[]) => Promise<unknown>;
|
|
113
|
+
};
|
|
114
|
+
type PostgresAdapterOptions = {
|
|
115
|
+
db: PostgresQueryable;
|
|
116
|
+
tableName?: string;
|
|
117
|
+
schemaName?: string;
|
|
118
|
+
};
|
|
119
|
+
declare function createPostgresAdapter({ db, tableName, schemaName, }: PostgresAdapterOptions): AnalyticsStorageAdapter;
|
|
120
|
+
|
|
121
|
+
type Server$5 = ReturnType<typeof createAnalyticsServer>;
|
|
122
|
+
type HeaderValue = string | string[] | undefined;
|
|
123
|
+
type ExpressLikeRequest = {
|
|
124
|
+
method?: string;
|
|
125
|
+
body?: unknown;
|
|
126
|
+
headers?: Record<string, HeaderValue>;
|
|
127
|
+
ip?: string;
|
|
128
|
+
get?: (name: string) => string | undefined;
|
|
129
|
+
header?: (name: string) => string | undefined;
|
|
130
|
+
};
|
|
131
|
+
type ExpressLikeResponse = {
|
|
132
|
+
status: (code: number) => ExpressLikeResponse;
|
|
133
|
+
json: (body: unknown) => unknown;
|
|
134
|
+
};
|
|
135
|
+
type ExpressIngestHandlerOptions = {
|
|
136
|
+
getContext?: (req: ExpressLikeRequest) => IngestContext;
|
|
137
|
+
};
|
|
138
|
+
declare function createExpressIngestHandler(server: Server$5, options?: ExpressIngestHandlerOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
139
|
+
|
|
140
|
+
type Server$4 = ReturnType<typeof createAnalyticsServer>;
|
|
141
|
+
declare function createExpressCatalogHandler(server: Server$4): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
142
|
+
|
|
143
|
+
type Server$3 = ReturnType<typeof createAnalyticsServer>;
|
|
144
|
+
declare function createFetchCatalogHandler(server: Server$3): (request: Request) => Promise<Response>;
|
|
145
|
+
|
|
146
|
+
type Server$2 = ReturnType<typeof createAnalyticsServer>;
|
|
147
|
+
declare function createFetchIngestHandler(server: Server$2): (request: Request) => Promise<Response>;
|
|
148
|
+
|
|
149
|
+
type Server$1 = ReturnType<typeof createAnalyticsServer>;
|
|
150
|
+
type CreateCatalogHandlerConfig = {
|
|
151
|
+
adapter?: 'fetch';
|
|
152
|
+
} | {
|
|
153
|
+
adapter: 'express';
|
|
154
|
+
};
|
|
155
|
+
declare function createCatalogHandler(server: Server$1, config?: {
|
|
156
|
+
adapter?: 'fetch';
|
|
157
|
+
}): (request: Request) => Promise<Response>;
|
|
158
|
+
declare function createCatalogHandler(server: Server$1, config: {
|
|
159
|
+
adapter: 'express';
|
|
160
|
+
}): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
161
|
+
|
|
162
|
+
type Server = ReturnType<typeof createAnalyticsServer>;
|
|
163
|
+
type CreateIngestHandlerConfig = {
|
|
164
|
+
adapter?: 'fetch';
|
|
165
|
+
} | {
|
|
166
|
+
adapter: 'express';
|
|
167
|
+
express?: ExpressIngestHandlerOptions;
|
|
168
|
+
};
|
|
169
|
+
declare function createIngestHandler(server: Server, config?: {
|
|
170
|
+
adapter?: 'fetch';
|
|
171
|
+
}): (request: Request) => Promise<Response>;
|
|
172
|
+
declare function createIngestHandler(server: Server, config: {
|
|
173
|
+
adapter: 'express';
|
|
174
|
+
express?: ExpressIngestHandlerOptions;
|
|
175
|
+
}): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
176
|
+
|
|
177
|
+
export { type AcceptedTrack, type AnalyticsEventInput, type AnalyticsServerConfig, type AnalyticsStorageAdapter, type AnalyticsTrackCatalog, type AnalyticsTrackCatalogItem, type CreateCatalogHandlerConfig, type CreateIngestHandlerConfig, type ExpressIngestHandlerOptions, type ExpressLikeRequest, type ExpressLikeResponse, type IngestContext, type IngestResult, type MaskConfig, type PostgresAdapterOptions, type PostgresQueryable, type StoredAnalyticsEvent, createAnalyticsServer, createCatalogHandler, createExpressCatalogHandler, createExpressIngestHandler, createFetchCatalogHandler, createFetchIngestHandler, createIngestHandler, createMemoryAdapter, createPostgresAdapter };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ZodType } from 'zod';
|
|
2
|
+
|
|
3
|
+
type AnalyticsEventProps = Record<string, unknown>;
|
|
4
|
+
type AnalyticsEventInput = {
|
|
5
|
+
name: string;
|
|
6
|
+
ts?: number;
|
|
7
|
+
appId: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
ref?: string;
|
|
12
|
+
environment?: string;
|
|
13
|
+
props?: AnalyticsEventProps;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
clientMeta?: {
|
|
16
|
+
ip?: string;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
isMobile?: boolean;
|
|
19
|
+
os?: string;
|
|
20
|
+
platform?: string;
|
|
21
|
+
language?: string;
|
|
22
|
+
screen?: {
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
writeKey?: string;
|
|
28
|
+
};
|
|
29
|
+
type IngestContext = {
|
|
30
|
+
ip?: string;
|
|
31
|
+
userAgent?: string;
|
|
32
|
+
headers?: Record<string, string | undefined>;
|
|
33
|
+
requestId?: string;
|
|
34
|
+
};
|
|
35
|
+
type StoredAnalyticsEvent = AnalyticsEventInput & {
|
|
36
|
+
ts: number;
|
|
37
|
+
receivedAt: number;
|
|
38
|
+
props: AnalyticsEventProps;
|
|
39
|
+
meta?: {
|
|
40
|
+
ip?: string;
|
|
41
|
+
userAgent?: string;
|
|
42
|
+
requestId?: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
interface AnalyticsStorageAdapter {
|
|
46
|
+
save: (event: StoredAnalyticsEvent) => Promise<void>;
|
|
47
|
+
getAll: (dateInit?: number, dateEnd?: number) => Promise<StoredAnalyticsEvent[]>;
|
|
48
|
+
clear: (dateInit?: number, dateEnd?: number) => Promise<number>;
|
|
49
|
+
}
|
|
50
|
+
type MaskFieldArgs = {
|
|
51
|
+
path: string;
|
|
52
|
+
key: string;
|
|
53
|
+
value: unknown;
|
|
54
|
+
event: StoredAnalyticsEvent;
|
|
55
|
+
};
|
|
56
|
+
type MaskConfig = {
|
|
57
|
+
paths?: string[];
|
|
58
|
+
keyPatterns?: RegExp[];
|
|
59
|
+
maskValue?: string;
|
|
60
|
+
maskField?: (args: MaskFieldArgs) => unknown;
|
|
61
|
+
};
|
|
62
|
+
type AcceptedTrack = {
|
|
63
|
+
trackName: string;
|
|
64
|
+
schema: ZodType;
|
|
65
|
+
validateOn?: 'props' | 'event';
|
|
66
|
+
version?: number;
|
|
67
|
+
description?: string;
|
|
68
|
+
tags?: string[];
|
|
69
|
+
deprecated?: boolean;
|
|
70
|
+
catalogSchema?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
type AnalyticsTrackCatalogItem = {
|
|
73
|
+
trackName: string;
|
|
74
|
+
validateOn: 'props' | 'event';
|
|
75
|
+
version: number;
|
|
76
|
+
description?: string;
|
|
77
|
+
tags?: string[];
|
|
78
|
+
deprecated?: boolean;
|
|
79
|
+
schema?: Record<string, unknown>;
|
|
80
|
+
};
|
|
81
|
+
type AnalyticsTrackCatalog = {
|
|
82
|
+
generatedAt: number;
|
|
83
|
+
tracks: AnalyticsTrackCatalogItem[];
|
|
84
|
+
};
|
|
85
|
+
type AnalyticsServerConfig = {
|
|
86
|
+
storage: AnalyticsStorageAdapter;
|
|
87
|
+
acceptedTracks?: AcceptedTrack[];
|
|
88
|
+
rejectUnknownTracks?: boolean;
|
|
89
|
+
masking?: MaskConfig;
|
|
90
|
+
};
|
|
91
|
+
type IngestErrorCode = 'invalid_payload' | 'track_not_allowed' | 'schema_mismatch' | 'storage_error';
|
|
92
|
+
type IngestResult = {
|
|
93
|
+
ok: true;
|
|
94
|
+
event: StoredAnalyticsEvent;
|
|
95
|
+
} | {
|
|
96
|
+
ok: false;
|
|
97
|
+
error: {
|
|
98
|
+
code: IngestErrorCode;
|
|
99
|
+
message: string;
|
|
100
|
+
details?: unknown;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
declare function createAnalyticsServer(config: AnalyticsServerConfig): {
|
|
105
|
+
ingest: (input: unknown, context?: IngestContext) => Promise<IngestResult>;
|
|
106
|
+
getCatalog: () => AnalyticsTrackCatalog;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
declare function createMemoryAdapter(initialEvents?: StoredAnalyticsEvent[]): AnalyticsStorageAdapter;
|
|
110
|
+
|
|
111
|
+
type PostgresQueryable = {
|
|
112
|
+
query: (text: string, values?: unknown[]) => Promise<unknown>;
|
|
113
|
+
};
|
|
114
|
+
type PostgresAdapterOptions = {
|
|
115
|
+
db: PostgresQueryable;
|
|
116
|
+
tableName?: string;
|
|
117
|
+
schemaName?: string;
|
|
118
|
+
};
|
|
119
|
+
declare function createPostgresAdapter({ db, tableName, schemaName, }: PostgresAdapterOptions): AnalyticsStorageAdapter;
|
|
120
|
+
|
|
121
|
+
type Server$5 = ReturnType<typeof createAnalyticsServer>;
|
|
122
|
+
type HeaderValue = string | string[] | undefined;
|
|
123
|
+
type ExpressLikeRequest = {
|
|
124
|
+
method?: string;
|
|
125
|
+
body?: unknown;
|
|
126
|
+
headers?: Record<string, HeaderValue>;
|
|
127
|
+
ip?: string;
|
|
128
|
+
get?: (name: string) => string | undefined;
|
|
129
|
+
header?: (name: string) => string | undefined;
|
|
130
|
+
};
|
|
131
|
+
type ExpressLikeResponse = {
|
|
132
|
+
status: (code: number) => ExpressLikeResponse;
|
|
133
|
+
json: (body: unknown) => unknown;
|
|
134
|
+
};
|
|
135
|
+
type ExpressIngestHandlerOptions = {
|
|
136
|
+
getContext?: (req: ExpressLikeRequest) => IngestContext;
|
|
137
|
+
};
|
|
138
|
+
declare function createExpressIngestHandler(server: Server$5, options?: ExpressIngestHandlerOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
139
|
+
|
|
140
|
+
type Server$4 = ReturnType<typeof createAnalyticsServer>;
|
|
141
|
+
declare function createExpressCatalogHandler(server: Server$4): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
142
|
+
|
|
143
|
+
type Server$3 = ReturnType<typeof createAnalyticsServer>;
|
|
144
|
+
declare function createFetchCatalogHandler(server: Server$3): (request: Request) => Promise<Response>;
|
|
145
|
+
|
|
146
|
+
type Server$2 = ReturnType<typeof createAnalyticsServer>;
|
|
147
|
+
declare function createFetchIngestHandler(server: Server$2): (request: Request) => Promise<Response>;
|
|
148
|
+
|
|
149
|
+
type Server$1 = ReturnType<typeof createAnalyticsServer>;
|
|
150
|
+
type CreateCatalogHandlerConfig = {
|
|
151
|
+
adapter?: 'fetch';
|
|
152
|
+
} | {
|
|
153
|
+
adapter: 'express';
|
|
154
|
+
};
|
|
155
|
+
declare function createCatalogHandler(server: Server$1, config?: {
|
|
156
|
+
adapter?: 'fetch';
|
|
157
|
+
}): (request: Request) => Promise<Response>;
|
|
158
|
+
declare function createCatalogHandler(server: Server$1, config: {
|
|
159
|
+
adapter: 'express';
|
|
160
|
+
}): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
161
|
+
|
|
162
|
+
type Server = ReturnType<typeof createAnalyticsServer>;
|
|
163
|
+
type CreateIngestHandlerConfig = {
|
|
164
|
+
adapter?: 'fetch';
|
|
165
|
+
} | {
|
|
166
|
+
adapter: 'express';
|
|
167
|
+
express?: ExpressIngestHandlerOptions;
|
|
168
|
+
};
|
|
169
|
+
declare function createIngestHandler(server: Server, config?: {
|
|
170
|
+
adapter?: 'fetch';
|
|
171
|
+
}): (request: Request) => Promise<Response>;
|
|
172
|
+
declare function createIngestHandler(server: Server, config: {
|
|
173
|
+
adapter: 'express';
|
|
174
|
+
express?: ExpressIngestHandlerOptions;
|
|
175
|
+
}): (req: ExpressLikeRequest, res: ExpressLikeResponse) => Promise<unknown>;
|
|
176
|
+
|
|
177
|
+
export { type AcceptedTrack, type AnalyticsEventInput, type AnalyticsServerConfig, type AnalyticsStorageAdapter, type AnalyticsTrackCatalog, type AnalyticsTrackCatalogItem, type CreateCatalogHandlerConfig, type CreateIngestHandlerConfig, type ExpressIngestHandlerOptions, type ExpressLikeRequest, type ExpressLikeResponse, type IngestContext, type IngestResult, type MaskConfig, type PostgresAdapterOptions, type PostgresQueryable, type StoredAnalyticsEvent, createAnalyticsServer, createCatalogHandler, createExpressCatalogHandler, createExpressIngestHandler, createFetchCatalogHandler, createFetchIngestHandler, createIngestHandler, createMemoryAdapter, createPostgresAdapter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createAnalyticsServer: () => createAnalyticsServer,
|
|
24
|
+
createCatalogHandler: () => createCatalogHandler,
|
|
25
|
+
createExpressCatalogHandler: () => createExpressCatalogHandler,
|
|
26
|
+
createExpressIngestHandler: () => createExpressIngestHandler,
|
|
27
|
+
createFetchCatalogHandler: () => createFetchCatalogHandler,
|
|
28
|
+
createFetchIngestHandler: () => createFetchIngestHandler,
|
|
29
|
+
createIngestHandler: () => createIngestHandler,
|
|
30
|
+
createMemoryAdapter: () => createMemoryAdapter,
|
|
31
|
+
createPostgresAdapter: () => createPostgresAdapter
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(index_exports);
|
|
34
|
+
|
|
35
|
+
// src/masking.ts
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
38
|
+
}
|
|
39
|
+
function shouldMask(path, key, config) {
|
|
40
|
+
if (config.paths?.includes(path)) return true;
|
|
41
|
+
if (config.keyPatterns?.some((pattern) => pattern.test(key))) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function maskRecursive(input, config, event, parentPath = "") {
|
|
45
|
+
if (Array.isArray(input)) {
|
|
46
|
+
return input.map(
|
|
47
|
+
(item, index) => maskRecursive(item, config, event, parentPath ? `${parentPath}.${index}` : String(index))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!isPlainObject(input)) return input;
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [key, value] of Object.entries(input)) {
|
|
53
|
+
const path = parentPath ? `${parentPath}.${key}` : key;
|
|
54
|
+
if (shouldMask(path, key, config)) {
|
|
55
|
+
out[key] = config.maskField ? config.maskField({ path, key, value, event }) : config.maskValue ?? "***";
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out[key] = maskRecursive(value, config, event, path);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function applyMasking(event, config) {
|
|
63
|
+
if (!config) return event;
|
|
64
|
+
return {
|
|
65
|
+
...event,
|
|
66
|
+
props: maskRecursive(event.props, config, event) ?? {}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/validation.ts
|
|
71
|
+
var import_zod = require("zod");
|
|
72
|
+
var baseEventSchema = import_zod.z.object({
|
|
73
|
+
name: import_zod.z.string().min(1),
|
|
74
|
+
ts: import_zod.z.number().optional(),
|
|
75
|
+
appId: import_zod.z.string().min(1),
|
|
76
|
+
sessionId: import_zod.z.string().optional(),
|
|
77
|
+
url: import_zod.z.string().optional(),
|
|
78
|
+
path: import_zod.z.string().optional(),
|
|
79
|
+
ref: import_zod.z.string().optional(),
|
|
80
|
+
environment: import_zod.z.string().optional(),
|
|
81
|
+
props: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional(),
|
|
82
|
+
tags: import_zod.z.array(import_zod.z.string()).optional(),
|
|
83
|
+
clientMeta: import_zod.z.object({
|
|
84
|
+
ip: import_zod.z.string().optional(),
|
|
85
|
+
userAgent: import_zod.z.string().optional(),
|
|
86
|
+
isMobile: import_zod.z.boolean().optional(),
|
|
87
|
+
os: import_zod.z.string().optional(),
|
|
88
|
+
platform: import_zod.z.string().optional(),
|
|
89
|
+
language: import_zod.z.string().optional(),
|
|
90
|
+
screen: import_zod.z.object({
|
|
91
|
+
width: import_zod.z.number(),
|
|
92
|
+
height: import_zod.z.number()
|
|
93
|
+
}).optional()
|
|
94
|
+
}).optional(),
|
|
95
|
+
writeKey: import_zod.z.string().optional()
|
|
96
|
+
});
|
|
97
|
+
function validateBasePayload(input) {
|
|
98
|
+
const parsed = baseEventSchema.safeParse(input);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: {
|
|
103
|
+
code: "invalid_payload",
|
|
104
|
+
message: "Invalid track payload",
|
|
105
|
+
details: parsed.error.issues
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, data: parsed.data };
|
|
110
|
+
}
|
|
111
|
+
function validateAgainstAcceptedTracks(event, acceptedTracks, rejectUnknownTracks) {
|
|
112
|
+
if (!acceptedTracks || acceptedTracks.length === 0) return null;
|
|
113
|
+
const trackConfig = acceptedTracks.find((item) => item.trackName === event.name);
|
|
114
|
+
if (!trackConfig) {
|
|
115
|
+
if (!rejectUnknownTracks) return null;
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: {
|
|
119
|
+
code: "track_not_allowed",
|
|
120
|
+
message: `Track '${event.name}' is not in the allowed track list`
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const target = trackConfig.validateOn === "event" ? event : event.props;
|
|
125
|
+
const parsed = trackConfig.schema.safeParse(target);
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: {
|
|
130
|
+
code: "schema_mismatch",
|
|
131
|
+
message: `Track '${event.name}' does not match the configured schema`,
|
|
132
|
+
details: parsed.error.issues
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/create-analytics-server.ts
|
|
140
|
+
function buildStoredEvent(input, context) {
|
|
141
|
+
return {
|
|
142
|
+
...input,
|
|
143
|
+
ts: input.ts ?? Date.now(),
|
|
144
|
+
receivedAt: Date.now(),
|
|
145
|
+
props: input.props ?? {},
|
|
146
|
+
meta: {
|
|
147
|
+
ip: context?.ip,
|
|
148
|
+
userAgent: context?.userAgent,
|
|
149
|
+
requestId: context?.requestId
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function createAnalyticsServer(config) {
|
|
154
|
+
const rejectUnknownTracks = config.rejectUnknownTracks ?? true;
|
|
155
|
+
const generatedAt = Date.now();
|
|
156
|
+
const trackCatalog = (config.acceptedTracks ?? []).map((track) => ({
|
|
157
|
+
trackName: track.trackName,
|
|
158
|
+
validateOn: track.validateOn ?? "props",
|
|
159
|
+
version: track.version ?? 1,
|
|
160
|
+
description: track.description,
|
|
161
|
+
tags: track.tags,
|
|
162
|
+
deprecated: track.deprecated,
|
|
163
|
+
schema: track.catalogSchema
|
|
164
|
+
}));
|
|
165
|
+
async function ingest(input, context) {
|
|
166
|
+
const payloadValidation = validateBasePayload(input);
|
|
167
|
+
if (!payloadValidation.ok) return payloadValidation;
|
|
168
|
+
const event = buildStoredEvent(payloadValidation.data, context);
|
|
169
|
+
const trackValidation = validateAgainstAcceptedTracks(
|
|
170
|
+
event,
|
|
171
|
+
config.acceptedTracks,
|
|
172
|
+
rejectUnknownTracks
|
|
173
|
+
);
|
|
174
|
+
if (trackValidation) return trackValidation;
|
|
175
|
+
const maskedEvent = applyMasking(event, config.masking);
|
|
176
|
+
try {
|
|
177
|
+
await config.storage.save(maskedEvent);
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
event: maskedEvent
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: {
|
|
186
|
+
code: "storage_error",
|
|
187
|
+
message: "Failed to persist track",
|
|
188
|
+
details: error instanceof Error ? error.message : error
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function getCatalog() {
|
|
194
|
+
return {
|
|
195
|
+
generatedAt,
|
|
196
|
+
tracks: [...trackCatalog]
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ingest,
|
|
201
|
+
getCatalog
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/adapters/memory-adapter.ts
|
|
206
|
+
function createMemoryAdapter(initialEvents = []) {
|
|
207
|
+
const events = [...initialEvents];
|
|
208
|
+
const adapter = {
|
|
209
|
+
async save(event) {
|
|
210
|
+
events.push(event);
|
|
211
|
+
},
|
|
212
|
+
async getAll(dateInit, dateEnd) {
|
|
213
|
+
if (dateInit === void 0 && dateEnd === void 0) return [...events];
|
|
214
|
+
return events.filter((event) => {
|
|
215
|
+
if (dateInit !== void 0 && event.ts < dateInit) return false;
|
|
216
|
+
if (dateEnd !== void 0 && event.ts > dateEnd) return false;
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
async clear(dateInit, dateEnd) {
|
|
221
|
+
const before = events.length;
|
|
222
|
+
if (dateInit === void 0 && dateEnd === void 0) {
|
|
223
|
+
events.length = 0;
|
|
224
|
+
return before;
|
|
225
|
+
}
|
|
226
|
+
const kept = events.filter((event) => {
|
|
227
|
+
if (dateInit !== void 0 && event.ts < dateInit) return true;
|
|
228
|
+
if (dateEnd !== void 0 && event.ts > dateEnd) return true;
|
|
229
|
+
if (dateInit === void 0 && dateEnd !== void 0) return event.ts > dateEnd;
|
|
230
|
+
return false;
|
|
231
|
+
});
|
|
232
|
+
events.length = 0;
|
|
233
|
+
events.push(...kept);
|
|
234
|
+
return before - events.length;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return adapter;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/adapters/postgres-adapter.ts
|
|
241
|
+
function quoteIdentifier(identifier) {
|
|
242
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
|
243
|
+
throw new Error(`Invalid SQL identifier: '${identifier}'`);
|
|
244
|
+
}
|
|
245
|
+
return `"${identifier}"`;
|
|
246
|
+
}
|
|
247
|
+
function buildDateRangeWhereClause(dateInit, dateEnd) {
|
|
248
|
+
const conditions = [];
|
|
249
|
+
const values = [];
|
|
250
|
+
if (dateInit !== void 0) {
|
|
251
|
+
conditions.push(`ts >= $${values.length + 1}`);
|
|
252
|
+
values.push(dateInit);
|
|
253
|
+
}
|
|
254
|
+
if (dateEnd !== void 0) {
|
|
255
|
+
conditions.push(`ts <= $${values.length + 1}`);
|
|
256
|
+
values.push(dateEnd);
|
|
257
|
+
}
|
|
258
|
+
if (conditions.length === 0) {
|
|
259
|
+
return { whereClause: "", values };
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
whereClause: ` where ${conditions.join(" and ")}`,
|
|
263
|
+
values
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function mapRowToStoredEvent(row) {
|
|
267
|
+
return {
|
|
268
|
+
name: row.name,
|
|
269
|
+
ts: row.ts,
|
|
270
|
+
appId: row.app_id,
|
|
271
|
+
sessionId: row.session_id ?? void 0,
|
|
272
|
+
url: row.url ?? void 0,
|
|
273
|
+
path: row.path ?? void 0,
|
|
274
|
+
ref: row.ref ?? void 0,
|
|
275
|
+
environment: row.environment ?? void 0,
|
|
276
|
+
props: row.props ?? {},
|
|
277
|
+
tags: row.tags ?? void 0,
|
|
278
|
+
clientMeta: row.client_meta ?? void 0,
|
|
279
|
+
writeKey: row.write_key ?? void 0,
|
|
280
|
+
receivedAt: row.received_at,
|
|
281
|
+
meta: row.meta ?? void 0
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function extractQueryResultData(result) {
|
|
285
|
+
if (typeof result === "object" && result !== null) {
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
function createPostgresAdapter({
|
|
291
|
+
db,
|
|
292
|
+
tableName = "analytics_events",
|
|
293
|
+
schemaName
|
|
294
|
+
}) {
|
|
295
|
+
const tableRef = schemaName ? `${quoteIdentifier(schemaName)}.${quoteIdentifier(tableName)}` : quoteIdentifier(tableName);
|
|
296
|
+
return {
|
|
297
|
+
async save(event) {
|
|
298
|
+
await db.query(
|
|
299
|
+
`
|
|
300
|
+
insert into ${tableRef}
|
|
301
|
+
(name, ts, app_id, session_id, url, path, ref, environment, props, tags, client_meta, write_key, received_at, meta)
|
|
302
|
+
values
|
|
303
|
+
($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::text[], $11::jsonb, $12, $13, $14::jsonb)
|
|
304
|
+
`,
|
|
305
|
+
[
|
|
306
|
+
event.name,
|
|
307
|
+
event.ts,
|
|
308
|
+
event.appId,
|
|
309
|
+
event.sessionId ?? null,
|
|
310
|
+
event.url ?? null,
|
|
311
|
+
event.path ?? null,
|
|
312
|
+
event.ref ?? null,
|
|
313
|
+
event.environment ?? null,
|
|
314
|
+
event.props,
|
|
315
|
+
event.tags ?? null,
|
|
316
|
+
event.clientMeta ?? null,
|
|
317
|
+
event.writeKey ?? null,
|
|
318
|
+
event.receivedAt,
|
|
319
|
+
event.meta ?? null
|
|
320
|
+
]
|
|
321
|
+
);
|
|
322
|
+
},
|
|
323
|
+
async getAll(dateInit, dateEnd) {
|
|
324
|
+
const { whereClause, values } = buildDateRangeWhereClause(dateInit, dateEnd);
|
|
325
|
+
const result = await db.query(
|
|
326
|
+
`
|
|
327
|
+
select
|
|
328
|
+
name,
|
|
329
|
+
ts,
|
|
330
|
+
app_id,
|
|
331
|
+
session_id,
|
|
332
|
+
url,
|
|
333
|
+
path,
|
|
334
|
+
ref,
|
|
335
|
+
environment,
|
|
336
|
+
props,
|
|
337
|
+
tags,
|
|
338
|
+
client_meta,
|
|
339
|
+
write_key,
|
|
340
|
+
received_at,
|
|
341
|
+
meta
|
|
342
|
+
from ${tableRef}${whereClause}
|
|
343
|
+
order by ts asc
|
|
344
|
+
`,
|
|
345
|
+
values
|
|
346
|
+
);
|
|
347
|
+
const rows = extractQueryResultData(result).rows ?? [];
|
|
348
|
+
return rows.map((row) => mapRowToStoredEvent(row));
|
|
349
|
+
},
|
|
350
|
+
async clear(dateInit, dateEnd) {
|
|
351
|
+
const { whereClause, values } = buildDateRangeWhereClause(dateInit, dateEnd);
|
|
352
|
+
const result = await db.query(`delete from ${tableRef}${whereClause}`, values);
|
|
353
|
+
return extractQueryResultData(result).rowCount ?? 0;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/http/create-express-catalog-handler.ts
|
|
359
|
+
function createExpressCatalogHandler(server) {
|
|
360
|
+
return async function handle(req, res) {
|
|
361
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") {
|
|
362
|
+
return res.status(405).json({ ok: false, error: "method_not_allowed" });
|
|
363
|
+
}
|
|
364
|
+
return res.status(200).json({ ok: true, data: server.getCatalog() });
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/http/create-express-handler.ts
|
|
369
|
+
function getHeader(req, name) {
|
|
370
|
+
const lower = name.toLowerCase();
|
|
371
|
+
if (typeof req.get === "function") return req.get(lower) ?? req.get(name) ?? void 0;
|
|
372
|
+
if (typeof req.header === "function") return req.header(lower) ?? req.header(name) ?? void 0;
|
|
373
|
+
const raw = req.headers?.[lower] ?? req.headers?.[name];
|
|
374
|
+
if (Array.isArray(raw)) return raw[0];
|
|
375
|
+
return raw;
|
|
376
|
+
}
|
|
377
|
+
function extractContextFromExpress(req) {
|
|
378
|
+
return {
|
|
379
|
+
ip: req.ip ?? getHeader(req, "x-forwarded-for"),
|
|
380
|
+
userAgent: getHeader(req, "user-agent"),
|
|
381
|
+
requestId: getHeader(req, "x-request-id")
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function resolveBody(body) {
|
|
385
|
+
if (typeof body === "string") {
|
|
386
|
+
return JSON.parse(body);
|
|
387
|
+
}
|
|
388
|
+
return body;
|
|
389
|
+
}
|
|
390
|
+
function createExpressIngestHandler(server, options = {}) {
|
|
391
|
+
return async function handle(req, res) {
|
|
392
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
393
|
+
return res.status(405).json({ ok: false, error: "method_not_allowed" });
|
|
394
|
+
}
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
body = resolveBody(req.body);
|
|
398
|
+
} catch {
|
|
399
|
+
return res.status(400).json({ ok: false, error: "invalid_json" });
|
|
400
|
+
}
|
|
401
|
+
const context = options.getContext?.(req) ?? extractContextFromExpress(req);
|
|
402
|
+
const result = await server.ingest(body, context);
|
|
403
|
+
if (!result.ok) {
|
|
404
|
+
const status = result.error.code === "storage_error" ? 500 : result.error.code === "invalid_payload" || result.error.code === "schema_mismatch" ? 422 : 403;
|
|
405
|
+
return res.status(status).json({ ok: false, error: result.error });
|
|
406
|
+
}
|
|
407
|
+
return res.status(202).json({ ok: true });
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/http/create-fetch-catalog-handler.ts
|
|
412
|
+
function createFetchCatalogHandler(server) {
|
|
413
|
+
return async function handle(request) {
|
|
414
|
+
if (request.method !== "GET") {
|
|
415
|
+
return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
|
|
416
|
+
}
|
|
417
|
+
return Response.json({ ok: true, data: server.getCatalog() }, { status: 200 });
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/http/create-fetch-handler.ts
|
|
422
|
+
function extractContext(request) {
|
|
423
|
+
return {
|
|
424
|
+
ip: request.headers.get("x-forwarded-for") ?? void 0,
|
|
425
|
+
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
426
|
+
requestId: request.headers.get("x-request-id") ?? void 0
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function createFetchIngestHandler(server) {
|
|
430
|
+
return async function handle(request) {
|
|
431
|
+
if (request.method !== "POST") {
|
|
432
|
+
return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
|
|
433
|
+
}
|
|
434
|
+
let body;
|
|
435
|
+
try {
|
|
436
|
+
body = await request.json();
|
|
437
|
+
} catch {
|
|
438
|
+
return Response.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
439
|
+
}
|
|
440
|
+
const result = await server.ingest(body, extractContext(request));
|
|
441
|
+
if (!result.ok) {
|
|
442
|
+
const status = result.error.code === "storage_error" ? 500 : result.error.code === "invalid_payload" || result.error.code === "schema_mismatch" ? 422 : 403;
|
|
443
|
+
return Response.json({ ok: false, error: result.error }, { status });
|
|
444
|
+
}
|
|
445
|
+
return Response.json({ ok: true }, { status: 202 });
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/http/create-catalog-handler.ts
|
|
450
|
+
function createCatalogHandler(server, config = {}) {
|
|
451
|
+
if (config.adapter === "express") {
|
|
452
|
+
return createExpressCatalogHandler(server);
|
|
453
|
+
}
|
|
454
|
+
return createFetchCatalogHandler(server);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/http/create-ingest-handler.ts
|
|
458
|
+
function createIngestHandler(server, config = {}) {
|
|
459
|
+
if (config.adapter === "express") {
|
|
460
|
+
return createExpressIngestHandler(server, config.express);
|
|
461
|
+
}
|
|
462
|
+
return createFetchIngestHandler(server);
|
|
463
|
+
}
|
|
464
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
465
|
+
0 && (module.exports = {
|
|
466
|
+
createAnalyticsServer,
|
|
467
|
+
createCatalogHandler,
|
|
468
|
+
createExpressCatalogHandler,
|
|
469
|
+
createExpressIngestHandler,
|
|
470
|
+
createFetchCatalogHandler,
|
|
471
|
+
createFetchIngestHandler,
|
|
472
|
+
createIngestHandler,
|
|
473
|
+
createMemoryAdapter,
|
|
474
|
+
createPostgresAdapter
|
|
475
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// src/masking.ts
|
|
2
|
+
function isPlainObject(value) {
|
|
3
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
4
|
+
}
|
|
5
|
+
function shouldMask(path, key, config) {
|
|
6
|
+
if (config.paths?.includes(path)) return true;
|
|
7
|
+
if (config.keyPatterns?.some((pattern) => pattern.test(key))) return true;
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
function maskRecursive(input, config, event, parentPath = "") {
|
|
11
|
+
if (Array.isArray(input)) {
|
|
12
|
+
return input.map(
|
|
13
|
+
(item, index) => maskRecursive(item, config, event, parentPath ? `${parentPath}.${index}` : String(index))
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
if (!isPlainObject(input)) return input;
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [key, value] of Object.entries(input)) {
|
|
19
|
+
const path = parentPath ? `${parentPath}.${key}` : key;
|
|
20
|
+
if (shouldMask(path, key, config)) {
|
|
21
|
+
out[key] = config.maskField ? config.maskField({ path, key, value, event }) : config.maskValue ?? "***";
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
out[key] = maskRecursive(value, config, event, path);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function applyMasking(event, config) {
|
|
29
|
+
if (!config) return event;
|
|
30
|
+
return {
|
|
31
|
+
...event,
|
|
32
|
+
props: maskRecursive(event.props, config, event) ?? {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/validation.ts
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
var baseEventSchema = z.object({
|
|
39
|
+
name: z.string().min(1),
|
|
40
|
+
ts: z.number().optional(),
|
|
41
|
+
appId: z.string().min(1),
|
|
42
|
+
sessionId: z.string().optional(),
|
|
43
|
+
url: z.string().optional(),
|
|
44
|
+
path: z.string().optional(),
|
|
45
|
+
ref: z.string().optional(),
|
|
46
|
+
environment: z.string().optional(),
|
|
47
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
48
|
+
tags: z.array(z.string()).optional(),
|
|
49
|
+
clientMeta: z.object({
|
|
50
|
+
ip: z.string().optional(),
|
|
51
|
+
userAgent: z.string().optional(),
|
|
52
|
+
isMobile: z.boolean().optional(),
|
|
53
|
+
os: z.string().optional(),
|
|
54
|
+
platform: z.string().optional(),
|
|
55
|
+
language: z.string().optional(),
|
|
56
|
+
screen: z.object({
|
|
57
|
+
width: z.number(),
|
|
58
|
+
height: z.number()
|
|
59
|
+
}).optional()
|
|
60
|
+
}).optional(),
|
|
61
|
+
writeKey: z.string().optional()
|
|
62
|
+
});
|
|
63
|
+
function validateBasePayload(input) {
|
|
64
|
+
const parsed = baseEventSchema.safeParse(input);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: {
|
|
69
|
+
code: "invalid_payload",
|
|
70
|
+
message: "Invalid track payload",
|
|
71
|
+
details: parsed.error.issues
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, data: parsed.data };
|
|
76
|
+
}
|
|
77
|
+
function validateAgainstAcceptedTracks(event, acceptedTracks, rejectUnknownTracks) {
|
|
78
|
+
if (!acceptedTracks || acceptedTracks.length === 0) return null;
|
|
79
|
+
const trackConfig = acceptedTracks.find((item) => item.trackName === event.name);
|
|
80
|
+
if (!trackConfig) {
|
|
81
|
+
if (!rejectUnknownTracks) return null;
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: {
|
|
85
|
+
code: "track_not_allowed",
|
|
86
|
+
message: `Track '${event.name}' is not in the allowed track list`
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const target = trackConfig.validateOn === "event" ? event : event.props;
|
|
91
|
+
const parsed = trackConfig.schema.safeParse(target);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: {
|
|
96
|
+
code: "schema_mismatch",
|
|
97
|
+
message: `Track '${event.name}' does not match the configured schema`,
|
|
98
|
+
details: parsed.error.issues
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/create-analytics-server.ts
|
|
106
|
+
function buildStoredEvent(input, context) {
|
|
107
|
+
return {
|
|
108
|
+
...input,
|
|
109
|
+
ts: input.ts ?? Date.now(),
|
|
110
|
+
receivedAt: Date.now(),
|
|
111
|
+
props: input.props ?? {},
|
|
112
|
+
meta: {
|
|
113
|
+
ip: context?.ip,
|
|
114
|
+
userAgent: context?.userAgent,
|
|
115
|
+
requestId: context?.requestId
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function createAnalyticsServer(config) {
|
|
120
|
+
const rejectUnknownTracks = config.rejectUnknownTracks ?? true;
|
|
121
|
+
const generatedAt = Date.now();
|
|
122
|
+
const trackCatalog = (config.acceptedTracks ?? []).map((track) => ({
|
|
123
|
+
trackName: track.trackName,
|
|
124
|
+
validateOn: track.validateOn ?? "props",
|
|
125
|
+
version: track.version ?? 1,
|
|
126
|
+
description: track.description,
|
|
127
|
+
tags: track.tags,
|
|
128
|
+
deprecated: track.deprecated,
|
|
129
|
+
schema: track.catalogSchema
|
|
130
|
+
}));
|
|
131
|
+
async function ingest(input, context) {
|
|
132
|
+
const payloadValidation = validateBasePayload(input);
|
|
133
|
+
if (!payloadValidation.ok) return payloadValidation;
|
|
134
|
+
const event = buildStoredEvent(payloadValidation.data, context);
|
|
135
|
+
const trackValidation = validateAgainstAcceptedTracks(
|
|
136
|
+
event,
|
|
137
|
+
config.acceptedTracks,
|
|
138
|
+
rejectUnknownTracks
|
|
139
|
+
);
|
|
140
|
+
if (trackValidation) return trackValidation;
|
|
141
|
+
const maskedEvent = applyMasking(event, config.masking);
|
|
142
|
+
try {
|
|
143
|
+
await config.storage.save(maskedEvent);
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
event: maskedEvent
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: {
|
|
152
|
+
code: "storage_error",
|
|
153
|
+
message: "Failed to persist track",
|
|
154
|
+
details: error instanceof Error ? error.message : error
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function getCatalog() {
|
|
160
|
+
return {
|
|
161
|
+
generatedAt,
|
|
162
|
+
tracks: [...trackCatalog]
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
ingest,
|
|
167
|
+
getCatalog
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/adapters/memory-adapter.ts
|
|
172
|
+
function createMemoryAdapter(initialEvents = []) {
|
|
173
|
+
const events = [...initialEvents];
|
|
174
|
+
const adapter = {
|
|
175
|
+
async save(event) {
|
|
176
|
+
events.push(event);
|
|
177
|
+
},
|
|
178
|
+
async getAll(dateInit, dateEnd) {
|
|
179
|
+
if (dateInit === void 0 && dateEnd === void 0) return [...events];
|
|
180
|
+
return events.filter((event) => {
|
|
181
|
+
if (dateInit !== void 0 && event.ts < dateInit) return false;
|
|
182
|
+
if (dateEnd !== void 0 && event.ts > dateEnd) return false;
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
async clear(dateInit, dateEnd) {
|
|
187
|
+
const before = events.length;
|
|
188
|
+
if (dateInit === void 0 && dateEnd === void 0) {
|
|
189
|
+
events.length = 0;
|
|
190
|
+
return before;
|
|
191
|
+
}
|
|
192
|
+
const kept = events.filter((event) => {
|
|
193
|
+
if (dateInit !== void 0 && event.ts < dateInit) return true;
|
|
194
|
+
if (dateEnd !== void 0 && event.ts > dateEnd) return true;
|
|
195
|
+
if (dateInit === void 0 && dateEnd !== void 0) return event.ts > dateEnd;
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
events.length = 0;
|
|
199
|
+
events.push(...kept);
|
|
200
|
+
return before - events.length;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return adapter;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/adapters/postgres-adapter.ts
|
|
207
|
+
function quoteIdentifier(identifier) {
|
|
208
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
|
209
|
+
throw new Error(`Invalid SQL identifier: '${identifier}'`);
|
|
210
|
+
}
|
|
211
|
+
return `"${identifier}"`;
|
|
212
|
+
}
|
|
213
|
+
function buildDateRangeWhereClause(dateInit, dateEnd) {
|
|
214
|
+
const conditions = [];
|
|
215
|
+
const values = [];
|
|
216
|
+
if (dateInit !== void 0) {
|
|
217
|
+
conditions.push(`ts >= $${values.length + 1}`);
|
|
218
|
+
values.push(dateInit);
|
|
219
|
+
}
|
|
220
|
+
if (dateEnd !== void 0) {
|
|
221
|
+
conditions.push(`ts <= $${values.length + 1}`);
|
|
222
|
+
values.push(dateEnd);
|
|
223
|
+
}
|
|
224
|
+
if (conditions.length === 0) {
|
|
225
|
+
return { whereClause: "", values };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
whereClause: ` where ${conditions.join(" and ")}`,
|
|
229
|
+
values
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function mapRowToStoredEvent(row) {
|
|
233
|
+
return {
|
|
234
|
+
name: row.name,
|
|
235
|
+
ts: row.ts,
|
|
236
|
+
appId: row.app_id,
|
|
237
|
+
sessionId: row.session_id ?? void 0,
|
|
238
|
+
url: row.url ?? void 0,
|
|
239
|
+
path: row.path ?? void 0,
|
|
240
|
+
ref: row.ref ?? void 0,
|
|
241
|
+
environment: row.environment ?? void 0,
|
|
242
|
+
props: row.props ?? {},
|
|
243
|
+
tags: row.tags ?? void 0,
|
|
244
|
+
clientMeta: row.client_meta ?? void 0,
|
|
245
|
+
writeKey: row.write_key ?? void 0,
|
|
246
|
+
receivedAt: row.received_at,
|
|
247
|
+
meta: row.meta ?? void 0
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function extractQueryResultData(result) {
|
|
251
|
+
if (typeof result === "object" && result !== null) {
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
function createPostgresAdapter({
|
|
257
|
+
db,
|
|
258
|
+
tableName = "analytics_events",
|
|
259
|
+
schemaName
|
|
260
|
+
}) {
|
|
261
|
+
const tableRef = schemaName ? `${quoteIdentifier(schemaName)}.${quoteIdentifier(tableName)}` : quoteIdentifier(tableName);
|
|
262
|
+
return {
|
|
263
|
+
async save(event) {
|
|
264
|
+
await db.query(
|
|
265
|
+
`
|
|
266
|
+
insert into ${tableRef}
|
|
267
|
+
(name, ts, app_id, session_id, url, path, ref, environment, props, tags, client_meta, write_key, received_at, meta)
|
|
268
|
+
values
|
|
269
|
+
($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::text[], $11::jsonb, $12, $13, $14::jsonb)
|
|
270
|
+
`,
|
|
271
|
+
[
|
|
272
|
+
event.name,
|
|
273
|
+
event.ts,
|
|
274
|
+
event.appId,
|
|
275
|
+
event.sessionId ?? null,
|
|
276
|
+
event.url ?? null,
|
|
277
|
+
event.path ?? null,
|
|
278
|
+
event.ref ?? null,
|
|
279
|
+
event.environment ?? null,
|
|
280
|
+
event.props,
|
|
281
|
+
event.tags ?? null,
|
|
282
|
+
event.clientMeta ?? null,
|
|
283
|
+
event.writeKey ?? null,
|
|
284
|
+
event.receivedAt,
|
|
285
|
+
event.meta ?? null
|
|
286
|
+
]
|
|
287
|
+
);
|
|
288
|
+
},
|
|
289
|
+
async getAll(dateInit, dateEnd) {
|
|
290
|
+
const { whereClause, values } = buildDateRangeWhereClause(dateInit, dateEnd);
|
|
291
|
+
const result = await db.query(
|
|
292
|
+
`
|
|
293
|
+
select
|
|
294
|
+
name,
|
|
295
|
+
ts,
|
|
296
|
+
app_id,
|
|
297
|
+
session_id,
|
|
298
|
+
url,
|
|
299
|
+
path,
|
|
300
|
+
ref,
|
|
301
|
+
environment,
|
|
302
|
+
props,
|
|
303
|
+
tags,
|
|
304
|
+
client_meta,
|
|
305
|
+
write_key,
|
|
306
|
+
received_at,
|
|
307
|
+
meta
|
|
308
|
+
from ${tableRef}${whereClause}
|
|
309
|
+
order by ts asc
|
|
310
|
+
`,
|
|
311
|
+
values
|
|
312
|
+
);
|
|
313
|
+
const rows = extractQueryResultData(result).rows ?? [];
|
|
314
|
+
return rows.map((row) => mapRowToStoredEvent(row));
|
|
315
|
+
},
|
|
316
|
+
async clear(dateInit, dateEnd) {
|
|
317
|
+
const { whereClause, values } = buildDateRangeWhereClause(dateInit, dateEnd);
|
|
318
|
+
const result = await db.query(`delete from ${tableRef}${whereClause}`, values);
|
|
319
|
+
return extractQueryResultData(result).rowCount ?? 0;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/http/create-express-catalog-handler.ts
|
|
325
|
+
function createExpressCatalogHandler(server) {
|
|
326
|
+
return async function handle(req, res) {
|
|
327
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") {
|
|
328
|
+
return res.status(405).json({ ok: false, error: "method_not_allowed" });
|
|
329
|
+
}
|
|
330
|
+
return res.status(200).json({ ok: true, data: server.getCatalog() });
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/http/create-express-handler.ts
|
|
335
|
+
function getHeader(req, name) {
|
|
336
|
+
const lower = name.toLowerCase();
|
|
337
|
+
if (typeof req.get === "function") return req.get(lower) ?? req.get(name) ?? void 0;
|
|
338
|
+
if (typeof req.header === "function") return req.header(lower) ?? req.header(name) ?? void 0;
|
|
339
|
+
const raw = req.headers?.[lower] ?? req.headers?.[name];
|
|
340
|
+
if (Array.isArray(raw)) return raw[0];
|
|
341
|
+
return raw;
|
|
342
|
+
}
|
|
343
|
+
function extractContextFromExpress(req) {
|
|
344
|
+
return {
|
|
345
|
+
ip: req.ip ?? getHeader(req, "x-forwarded-for"),
|
|
346
|
+
userAgent: getHeader(req, "user-agent"),
|
|
347
|
+
requestId: getHeader(req, "x-request-id")
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function resolveBody(body) {
|
|
351
|
+
if (typeof body === "string") {
|
|
352
|
+
return JSON.parse(body);
|
|
353
|
+
}
|
|
354
|
+
return body;
|
|
355
|
+
}
|
|
356
|
+
function createExpressIngestHandler(server, options = {}) {
|
|
357
|
+
return async function handle(req, res) {
|
|
358
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
359
|
+
return res.status(405).json({ ok: false, error: "method_not_allowed" });
|
|
360
|
+
}
|
|
361
|
+
let body;
|
|
362
|
+
try {
|
|
363
|
+
body = resolveBody(req.body);
|
|
364
|
+
} catch {
|
|
365
|
+
return res.status(400).json({ ok: false, error: "invalid_json" });
|
|
366
|
+
}
|
|
367
|
+
const context = options.getContext?.(req) ?? extractContextFromExpress(req);
|
|
368
|
+
const result = await server.ingest(body, context);
|
|
369
|
+
if (!result.ok) {
|
|
370
|
+
const status = result.error.code === "storage_error" ? 500 : result.error.code === "invalid_payload" || result.error.code === "schema_mismatch" ? 422 : 403;
|
|
371
|
+
return res.status(status).json({ ok: false, error: result.error });
|
|
372
|
+
}
|
|
373
|
+
return res.status(202).json({ ok: true });
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/http/create-fetch-catalog-handler.ts
|
|
378
|
+
function createFetchCatalogHandler(server) {
|
|
379
|
+
return async function handle(request) {
|
|
380
|
+
if (request.method !== "GET") {
|
|
381
|
+
return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
|
|
382
|
+
}
|
|
383
|
+
return Response.json({ ok: true, data: server.getCatalog() }, { status: 200 });
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/http/create-fetch-handler.ts
|
|
388
|
+
function extractContext(request) {
|
|
389
|
+
return {
|
|
390
|
+
ip: request.headers.get("x-forwarded-for") ?? void 0,
|
|
391
|
+
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
392
|
+
requestId: request.headers.get("x-request-id") ?? void 0
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function createFetchIngestHandler(server) {
|
|
396
|
+
return async function handle(request) {
|
|
397
|
+
if (request.method !== "POST") {
|
|
398
|
+
return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
|
|
399
|
+
}
|
|
400
|
+
let body;
|
|
401
|
+
try {
|
|
402
|
+
body = await request.json();
|
|
403
|
+
} catch {
|
|
404
|
+
return Response.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
405
|
+
}
|
|
406
|
+
const result = await server.ingest(body, extractContext(request));
|
|
407
|
+
if (!result.ok) {
|
|
408
|
+
const status = result.error.code === "storage_error" ? 500 : result.error.code === "invalid_payload" || result.error.code === "schema_mismatch" ? 422 : 403;
|
|
409
|
+
return Response.json({ ok: false, error: result.error }, { status });
|
|
410
|
+
}
|
|
411
|
+
return Response.json({ ok: true }, { status: 202 });
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/http/create-catalog-handler.ts
|
|
416
|
+
function createCatalogHandler(server, config = {}) {
|
|
417
|
+
if (config.adapter === "express") {
|
|
418
|
+
return createExpressCatalogHandler(server);
|
|
419
|
+
}
|
|
420
|
+
return createFetchCatalogHandler(server);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/http/create-ingest-handler.ts
|
|
424
|
+
function createIngestHandler(server, config = {}) {
|
|
425
|
+
if (config.adapter === "express") {
|
|
426
|
+
return createExpressIngestHandler(server, config.express);
|
|
427
|
+
}
|
|
428
|
+
return createFetchIngestHandler(server);
|
|
429
|
+
}
|
|
430
|
+
export {
|
|
431
|
+
createAnalyticsServer,
|
|
432
|
+
createCatalogHandler,
|
|
433
|
+
createExpressCatalogHandler,
|
|
434
|
+
createExpressIngestHandler,
|
|
435
|
+
createFetchCatalogHandler,
|
|
436
|
+
createFetchIngestHandler,
|
|
437
|
+
createIngestHandler,
|
|
438
|
+
createMemoryAdapter,
|
|
439
|
+
createPostgresAdapter
|
|
440
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xray-analytics/analytics-server",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"registry": "https://registry.npmjs.org/"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
25
|
+
"lint": "eslint .",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"zod": "^4.1.5"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.2.3"
|
|
34
|
+
}
|
|
35
|
+
}
|