@knowledgefutures/sdk 0.1.0 → 0.2.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.
@@ -1,135 +0,0 @@
1
- /**
2
- * The event format: what a client sends (a hint), the checks it must pass,
3
- * and the fixed row it becomes in the shared `kf.events` table. The SDK and
4
- * the `kf/analytics` collector both import this file, so the format has
5
- * exactly one definition.
6
- *
7
- * Ported from the mitp-oecs collector (schema 1). Schema 2 adds `site`,
8
- * `environment`, `source` and `scope`, lets any lowercase word be a subject
9
- * type, and lets a server event say where its reader was.
10
- *
11
- * The rule the format exists for: what can be read off the request is
12
- * derived by the collector and never taken from a browser. A beacon cannot
13
- * name its project, its time, its country or its device. Only `referrer`
14
- * is necessarily client-declared (a beacon's own Referer header is the page
15
- * it was sent from, not where the reader came from), so `referrer_host` and
16
- * `is_unique` are the two fields a determined client can move.
17
- */
18
- export declare const SCHEMA = 2;
19
- export declare const KINDS: readonly ["view", "download", "search", "custom"];
20
- export type Kind = (typeof KINDS)[number];
21
- /**
22
- * `beacon`: a browser, attributed by its Origin or a public site key —
23
- * plausible, never proven. `server`: the project's own server, signed with
24
- * its secret.
25
- */
26
- export type Source = 'beacon' | 'server';
27
- export declare const UTM_KEYS: readonly ["source", "medium", "campaign", "term", "content"];
28
- export type UtmKey = (typeof UTM_KEYS)[number];
29
- /** What a page or a server sends. Everything is optional except `kind`. */
30
- export interface Hint {
31
- kind: Kind;
32
- /** `<type>:<id>`, e.g. `pub:za65x7nh`. The type is a lowercase word of the project's choosing. */
33
- subject?: string;
34
- /** A stable identifier for the subject, usually a DOI URL. */
35
- subjectUri?: string;
36
- path?: string;
37
- /** Where the reader came from: `document.referrer`, not the current page. */
38
- referrer?: string;
39
- /** The download format, the search query, or a custom event's name. Required for `custom`. */
40
- name?: string;
41
- /** The project's own tenant, e.g. a PubPub community id. */
42
- scope?: string;
43
- /** Project-specific detail: at most 16 string values. */
44
- props?: Record<string, string>;
45
- utm?: Partial<Record<UtmKey, string>>;
46
- }
47
- /**
48
- * A server event may also say where its reader was: the Worker that served a
49
- * PDF knows the reader's country and user agent, and the collector, which
50
- * only sees the project's server, does not.
51
- */
52
- export interface ServerHint extends Hint {
53
- hostname?: string;
54
- country?: string;
55
- userAgent?: string;
56
- }
57
- /** One row of `kf.events`. Column order is `FIELDS` order. */
58
- export interface EventRow {
59
- schema: number;
60
- site: string;
61
- project: string;
62
- environment: string;
63
- source: Source;
64
- ts: string;
65
- day: string;
66
- kind: Kind;
67
- subject_type: string | null;
68
- subject_id: string | null;
69
- subject_uri: string | null;
70
- path: string | null;
71
- hostname: string | null;
72
- country: string | null;
73
- device: Device;
74
- referrer_host: string | null;
75
- is_unique: boolean;
76
- utm_source: string | null;
77
- utm_medium: string | null;
78
- utm_campaign: string | null;
79
- utm_term: string | null;
80
- utm_content: string | null;
81
- name: string | null;
82
- scope: string | null;
83
- props: Record<string, string>;
84
- }
85
- export type Device = 'bot' | 'tablet' | 'mobile' | 'desktop' | 'unknown';
86
- /**
87
- * The Cloudflare Pipelines stream schema, generated from here rather than
88
- * kept beside it: `wrangler pipelines streams create --schema-file`.
89
- */
90
- export declare const FIELDS: ReadonlyArray<{
91
- name: keyof EventRow;
92
- type: 'int32' | 'string' | 'timestamp' | 'bool' | 'json';
93
- required: boolean;
94
- }>;
95
- /**
96
- * Caps. A hint is a few hundred bytes; anything near these is abuse or a bug.
97
- * Over-long values of the right type are truncated; the wrong type is a 422.
98
- */
99
- export declare const LIMITS: {
100
- /** Characters of request body. */
101
- readonly body: 4096;
102
- readonly props: 16;
103
- readonly propKey: 40;
104
- readonly propValue: 200;
105
- readonly subjectId: 64;
106
- readonly scope: 64;
107
- /** `subjectUri` and `referrer`. */
108
- readonly uri: 500;
109
- readonly path: 300;
110
- /** `name`, each `utm_*`, a server hint's `hostname` and `userAgent`. */
111
- readonly text: 200;
112
- };
113
- /**
114
- * Why a hint is refused, in words a developer can act on, or null when it is
115
- * fine. Unknown top-level keys are refused: that is what keeps one table
116
- * combinable across every project. Project detail goes in `props`.
117
- */
118
- export declare function validateHint(hint: unknown, source: Source): string | null;
119
- /** What the collector knows from the request and its site registry, never from the hint. */
120
- export interface RowContext {
121
- site: string;
122
- project: string;
123
- environment: string;
124
- source: Source;
125
- /** A beacon's page host (from its Origin). For a server event, the hint's `hostname` wins. */
126
- hostname: string | null;
127
- /** `request.cf.country`. For a server event, the hint's `country` wins. */
128
- country: string | null;
129
- /** For a server event, the hint's `userAgent` wins. */
130
- userAgent: string;
131
- now: Date;
132
- }
133
- /** The row a hint becomes. Call `validateHint` first; this trusts its input's types. */
134
- export declare function buildRow(hint: Hint | ServerHint, ctx: RowContext): EventRow;
135
- export declare function device(userAgent: string): Device;
@@ -1,231 +0,0 @@
1
- /**
2
- * The event format: what a client sends (a hint), the checks it must pass,
3
- * and the fixed row it becomes in the shared `kf.events` table. The SDK and
4
- * the `kf/analytics` collector both import this file, so the format has
5
- * exactly one definition.
6
- *
7
- * Ported from the mitp-oecs collector (schema 1). Schema 2 adds `site`,
8
- * `environment`, `source` and `scope`, lets any lowercase word be a subject
9
- * type, and lets a server event say where its reader was.
10
- *
11
- * The rule the format exists for: what can be read off the request is
12
- * derived by the collector and never taken from a browser. A beacon cannot
13
- * name its project, its time, its country or its device. Only `referrer`
14
- * is necessarily client-declared (a beacon's own Referer header is the page
15
- * it was sent from, not where the reader came from), so `referrer_host` and
16
- * `is_unique` are the two fields a determined client can move.
17
- */
18
- export const SCHEMA = 2;
19
- export const KINDS = ['view', 'download', 'search', 'custom'];
20
- export const UTM_KEYS = ['source', 'medium', 'campaign', 'term', 'content'];
21
- /**
22
- * The Cloudflare Pipelines stream schema, generated from here rather than
23
- * kept beside it: `wrangler pipelines streams create --schema-file`.
24
- */
25
- export const FIELDS = [
26
- { name: 'schema', type: 'int32', required: true },
27
- { name: 'site', type: 'string', required: true },
28
- { name: 'project', type: 'string', required: true },
29
- { name: 'environment', type: 'string', required: true },
30
- { name: 'source', type: 'string', required: true },
31
- { name: 'ts', type: 'timestamp', required: true },
32
- { name: 'day', type: 'string', required: true },
33
- { name: 'kind', type: 'string', required: true },
34
- { name: 'subject_type', type: 'string', required: false },
35
- { name: 'subject_id', type: 'string', required: false },
36
- { name: 'subject_uri', type: 'string', required: false },
37
- { name: 'path', type: 'string', required: false },
38
- { name: 'hostname', type: 'string', required: false },
39
- { name: 'country', type: 'string', required: false },
40
- { name: 'device', type: 'string', required: true },
41
- { name: 'referrer_host', type: 'string', required: false },
42
- { name: 'is_unique', type: 'bool', required: true },
43
- { name: 'utm_source', type: 'string', required: false },
44
- { name: 'utm_medium', type: 'string', required: false },
45
- { name: 'utm_campaign', type: 'string', required: false },
46
- { name: 'utm_term', type: 'string', required: false },
47
- { name: 'utm_content', type: 'string', required: false },
48
- { name: 'name', type: 'string', required: false },
49
- { name: 'scope', type: 'string', required: false },
50
- { name: 'props', type: 'json', required: false },
51
- ];
52
- /**
53
- * Caps. A hint is a few hundred bytes; anything near these is abuse or a bug.
54
- * Over-long values of the right type are truncated; the wrong type is a 422.
55
- */
56
- export const LIMITS = {
57
- /** Characters of request body. */
58
- body: 4096,
59
- props: 16,
60
- propKey: 40,
61
- propValue: 200,
62
- subjectId: 64,
63
- scope: 64,
64
- /** `subjectUri` and `referrer`. */
65
- uri: 500,
66
- path: 300,
67
- /** `name`, each `utm_*`, a server hint's `hostname` and `userAgent`. */
68
- text: 200,
69
- };
70
- const SUBJECT_TYPE = /^[a-z][a-z0-9-]{0,31}$/;
71
- const COUNTRY = /^[A-Z]{2}$/;
72
- const CLIENT_KEYS = new Set([
73
- 'kind',
74
- 'subject',
75
- 'subjectUri',
76
- 'path',
77
- 'referrer',
78
- 'name',
79
- 'scope',
80
- 'props',
81
- 'utm',
82
- ]);
83
- const SERVER_KEYS = new Set([
84
- ...CLIENT_KEYS,
85
- 'hostname',
86
- 'country',
87
- 'userAgent',
88
- ]);
89
- const OPTIONAL_STRINGS = ['subject', 'subjectUri', 'path', 'referrer', 'name', 'scope'];
90
- /**
91
- * Why a hint is refused, in words a developer can act on, or null when it is
92
- * fine. Unknown top-level keys are refused: that is what keeps one table
93
- * combinable across every project. Project detail goes in `props`.
94
- */
95
- export function validateHint(hint, source) {
96
- if (!hint || typeof hint !== 'object' || Array.isArray(hint))
97
- return 'body must be an object';
98
- const h = hint;
99
- const allowed = source === 'server' ? SERVER_KEYS : CLIENT_KEYS;
100
- for (const key of Object.keys(h)) {
101
- if (!allowed.has(key)) {
102
- return SERVER_KEYS.has(key)
103
- ? `"${key}" is accepted from a server event only`
104
- : `unknown key "${key}"`;
105
- }
106
- }
107
- if (!KINDS.includes(h.kind))
108
- return `kind must be one of ${KINDS.join(', ')}`;
109
- for (const key of OPTIONAL_STRINGS) {
110
- if (h[key] !== undefined && typeof h[key] !== 'string')
111
- return `${key} must be a string`;
112
- }
113
- if (h.kind === 'custom' && !h.name)
114
- return 'custom events need a name';
115
- if (typeof h.subject === 'string') {
116
- const colon = h.subject.indexOf(':');
117
- const type = colon < 0 ? h.subject : h.subject.slice(0, colon);
118
- if (!SUBJECT_TYPE.test(type) || colon === h.subject.length - 1) {
119
- return 'subject must be "<type>:<id>", the type a lowercase word such as pub or page';
120
- }
121
- }
122
- if (h.props !== undefined) {
123
- if (!h.props || typeof h.props !== 'object' || Array.isArray(h.props)) {
124
- return 'props must be an object';
125
- }
126
- const entries = Object.entries(h.props);
127
- if (entries.length > LIMITS.props) {
128
- return `props has ${entries.length} keys, at most ${LIMITS.props} are allowed`;
129
- }
130
- for (const [k, v] of entries) {
131
- if (k.length > LIMITS.propKey) {
132
- return `props key "${k.slice(0, LIMITS.propKey)}…" is longer than ${LIMITS.propKey} characters`;
133
- }
134
- if (typeof v !== 'string')
135
- return `props.${k} must be a string`;
136
- if (v.length > LIMITS.propValue) {
137
- return `props.${k} is longer than ${LIMITS.propValue} characters`;
138
- }
139
- }
140
- }
141
- if (h.utm !== undefined) {
142
- if (!h.utm || typeof h.utm !== 'object' || Array.isArray(h.utm))
143
- return 'utm must be an object';
144
- for (const [k, v] of Object.entries(h.utm)) {
145
- if (!UTM_KEYS.includes(k))
146
- return `utm.${k} is not one of ${UTM_KEYS.join(', ')}`;
147
- if (typeof v !== 'string')
148
- return `utm.${k} must be a string`;
149
- }
150
- }
151
- if (source === 'server') {
152
- for (const key of ['hostname', 'userAgent']) {
153
- if (h[key] !== undefined && typeof h[key] !== 'string')
154
- return `${key} must be a string`;
155
- }
156
- if (h.country !== undefined && (typeof h.country !== 'string' || !COUNTRY.test(h.country))) {
157
- return 'country must be a two-letter code in capitals';
158
- }
159
- }
160
- return null;
161
- }
162
- /** The row a hint becomes. Call `validateHint` first; this trusts its input's types. */
163
- export function buildRow(hint, ctx) {
164
- const server = ctx.source === 'server' ? hint : undefined;
165
- const hostname = clip(server?.hostname, LIMITS.text)?.toLowerCase() ?? ctx.hostname;
166
- const country = server ? (server.country ?? null) : ctx.country;
167
- const userAgent = server ? (server.userAgent ?? '') : ctx.userAgent;
168
- let subjectType = null;
169
- let subjectId = null;
170
- if (hint.subject) {
171
- const colon = hint.subject.indexOf(':');
172
- subjectType = colon < 0 ? hint.subject : hint.subject.slice(0, colon);
173
- subjectId = colon < 0 ? null : hint.subject.slice(colon + 1, colon + 1 + LIMITS.subjectId);
174
- }
175
- const referrerHost = hostOf(hint.referrer);
176
- const utm = hint.utm ?? {};
177
- const ts = ctx.now.toISOString();
178
- return {
179
- schema: SCHEMA,
180
- site: ctx.site,
181
- project: ctx.project,
182
- environment: ctx.environment,
183
- source: ctx.source,
184
- ts,
185
- day: ts.slice(0, 10),
186
- kind: hint.kind,
187
- subject_type: subjectType,
188
- subject_id: subjectId,
189
- subject_uri: clip(hint.subjectUri, LIMITS.uri),
190
- path: clip(hint.path, LIMITS.path),
191
- hostname,
192
- country,
193
- device: device(userAgent),
194
- referrer_host: referrerHost,
195
- // The pubpub heuristic: no referrer, or one from another host, is a fresh
196
- // visit. It counts visits, not people — no cookie, no fingerprint, no IP.
197
- is_unique: !referrerHost || referrerHost !== hostname,
198
- utm_source: clip(utm.source, LIMITS.text),
199
- utm_medium: clip(utm.medium, LIMITS.text),
200
- utm_campaign: clip(utm.campaign, LIMITS.text),
201
- utm_term: clip(utm.term, LIMITS.text),
202
- utm_content: clip(utm.content, LIMITS.text),
203
- name: clip(hint.name, LIMITS.text),
204
- scope: clip(hint.scope, LIMITS.scope),
205
- props: hint.props ?? {},
206
- };
207
- }
208
- export function device(userAgent) {
209
- if (!userAgent)
210
- return 'unknown';
211
- if (/bot|crawl|spider|slurp|fetch/i.test(userAgent))
212
- return 'bot';
213
- if (/iPad|Tablet/i.test(userAgent))
214
- return 'tablet';
215
- if (/Mobi|Android|iPhone/i.test(userAgent))
216
- return 'mobile';
217
- return 'desktop';
218
- }
219
- function clip(v, max) {
220
- return v ? v.slice(0, max) : null;
221
- }
222
- function hostOf(url) {
223
- if (!url)
224
- return null;
225
- try {
226
- return new URL(url.slice(0, LIMITS.uri)).hostname.toLowerCase() || null;
227
- }
228
- catch {
229
- return null;
230
- }
231
- }
@@ -1,24 +0,0 @@
1
- /**
2
- * The only error project code sees from analytics. The collector's statuses
3
- * map onto these codes, so a `catch` written against the memory double
4
- * handles the live service the same way.
5
- */
6
- export type AnalyticsErrorCode =
7
- /** No binding: the environment doesn't declare `kf/analytics@1`. */
8
- 'not_configured'
9
- /** The event or query itself is wrong. The message names the field. Fix the call. */
10
- | 'invalid'
11
- /** The collector refused the site's credentials. */
12
- | 'forbidden'
13
- /** The collector is down, unreachable or rate-limiting. Safe to retry. */
14
- | 'unavailable';
15
- export declare class AnalyticsError extends Error {
16
- readonly code: AnalyticsErrorCode;
17
- /** The collector's HTTP status, when there was one. For logs, not for branching. */
18
- readonly status?: number | undefined;
19
- readonly name = "AnalyticsError";
20
- constructor(code: AnalyticsErrorCode, message: string,
21
- /** The collector's HTTP status, when there was one. For logs, not for branching. */
22
- status?: number | undefined);
23
- }
24
- export declare function isAnalyticsError(e: unknown, code?: AnalyticsErrorCode): e is AnalyticsError;
@@ -1,20 +0,0 @@
1
- /**
2
- * The only error project code sees from analytics. The collector's statuses
3
- * map onto these codes, so a `catch` written against the memory double
4
- * handles the live service the same way.
5
- */
6
- export class AnalyticsError extends Error {
7
- code;
8
- status;
9
- name = 'AnalyticsError';
10
- constructor(code, message,
11
- /** The collector's HTTP status, when there was one. For logs, not for branching. */
12
- status) {
13
- super(message);
14
- this.code = code;
15
- this.status = status;
16
- }
17
- }
18
- export function isAnalyticsError(e, code) {
19
- return e instanceof AnalyticsError && (code === undefined || e.code === code);
20
- }
@@ -1,38 +0,0 @@
1
- import { ANALYTICS_ENV } from './binding.js';
2
- import type { Analytics } from './types.js';
3
- /**
4
- * `@knowledgefutures/sdk/analytics` — numbers for a project that declares
5
- * `kf/analytics@1`, with nothing to configure:
6
- *
7
- * import { analytics } from '@knowledgefutures/sdk/analytics'
8
- * analytics.beaconTag() // into every page's <head>
9
- * await analytics.track({ kind: 'download', subject: 'pub:za65x7nh', name: 'pdf' })
10
- * const { rows } = await analytics.query({ cube: 'subjects', from, to, groupBy: ['subject_id'], order: '-views', limit: 10 })
11
- *
12
- * The console writes `KF_ANALYTICS_URL` into every environment that declares
13
- * the service; `kf dev` writes `log:`, which prints events and answers
14
- * queries from memory. Code never sees which it has.
15
- */
16
- export type * from './types.js';
17
- export type { Cube, CubeRow, Dim, Metric, Query, QueryResult } from './cubes.js';
18
- export type { Hint, Kind, ServerHint } from './envelope.js';
19
- export { CUBES } from './cubes.js';
20
- export { AnalyticsError, isAnalyticsError, type AnalyticsErrorCode } from './errors.js';
21
- type Env = Partial<Record<typeof ANALYTICS_ENV, string | undefined>>;
22
- export interface AnalyticsOptions {
23
- /** For tests, or a runtime whose global fetch needs wrapping. */
24
- fetch?: typeof fetch;
25
- }
26
- /**
27
- * A client from an explicit environment: a Worker's `env` (when
28
- * `nodejs_compat` is off, `process.env` is empty), a script's, a test's. A
29
- * string is the binding itself.
30
- */
31
- export declare function createAnalytics(env: Env | string, options?: AnalyticsOptions): Analytics;
32
- /**
33
- * The zero-config client. It reads `process.env` on first use, never at
34
- * import, so importing it costs nothing where analytics isn't declared and a
35
- * missing binding fails at the call that needed it, with the fix in the
36
- * message.
37
- */
38
- export declare const analytics: Analytics;
@@ -1,98 +0,0 @@
1
- import { ANALYTICS_ENV, decodeAnalyticsBinding } from './binding.js';
2
- import { validateQuery } from './cubes.js';
3
- import { validateHint } from './envelope.js';
4
- import { AnalyticsError } from './errors.js';
5
- import { memoryAnalytics } from './memory.js';
6
- export { CUBES } from './cubes.js';
7
- export { AnalyticsError, isAnalyticsError } from './errors.js';
8
- /**
9
- * A client from an explicit environment: a Worker's `env` (when
10
- * `nodejs_compat` is off, `process.env` is empty), a script's, a test's. A
11
- * string is the binding itself.
12
- */
13
- export function createAnalytics(env, options = {}) {
14
- const value = typeof env === 'string' ? env : env[ANALYTICS_ENV];
15
- if (!value)
16
- throw unconfigured();
17
- const binding = decodeAnalyticsBinding(value);
18
- if (binding === 'log') {
19
- return memoryAnalytics({
20
- site: 'local',
21
- environment: 'local',
22
- log: (row) => console.log('kf-analytics', JSON.stringify(row)),
23
- });
24
- }
25
- return remote(binding, options.fetch ?? ((...a) => fetch(...a)));
26
- }
27
- function remote(b, doFetch) {
28
- const auth = `Basic ${btoa(`${b.site}:${b.secret}`)}`;
29
- const call = async (path, body) => {
30
- let res;
31
- try {
32
- res = await doFetch(`${b.endpoint}${path}`, {
33
- method: 'POST',
34
- headers: { authorization: auth, 'content-type': 'application/json' },
35
- body: JSON.stringify(body),
36
- });
37
- }
38
- catch (e) {
39
- throw new AnalyticsError('unavailable', `the collector is unreachable: ${String(e)}`);
40
- }
41
- if (res.ok)
42
- return res;
43
- const detail = (await res.text().catch(() => '')).slice(0, 300) || res.statusText;
44
- const code = res.status === 401 || res.status === 403
45
- ? 'forbidden'
46
- : res.status === 429 || res.status >= 500
47
- ? 'unavailable'
48
- : 'invalid';
49
- throw new AnalyticsError(code, `${path}: ${detail}`, res.status);
50
- };
51
- return {
52
- async track(event) {
53
- const problem = validateHint(event, 'server');
54
- if (problem)
55
- throw new AnalyticsError('invalid', problem);
56
- await call('/v1/e', event);
57
- },
58
- async query(q) {
59
- const problem = validateQuery(q);
60
- if (problem)
61
- throw new AnalyticsError('invalid', problem);
62
- const res = await call('/v1/query', q);
63
- return (await res.json());
64
- },
65
- beaconTag({ keyed = false } = {}) {
66
- const site = keyed ? ` data-site="${b.site}"` : '';
67
- return `<script src="${b.endpoint}/v1/b.js"${site} defer></script>`;
68
- },
69
- };
70
- }
71
- /**
72
- * The zero-config client. It reads `process.env` on first use, never at
73
- * import, so importing it costs nothing where analytics isn't declared and a
74
- * missing binding fails at the call that needed it, with the fix in the
75
- * message.
76
- */
77
- export const analytics = (() => {
78
- let resolved;
79
- const current = () => {
80
- if (resolved)
81
- return resolved;
82
- const env = globalThis.process?.env;
83
- if (!env?.[ANALYTICS_ENV])
84
- throw unconfigured();
85
- resolved = createAnalytics(env);
86
- return resolved;
87
- };
88
- // async, so a missing binding is a rejected promise like every other failure, not a throw.
89
- return {
90
- track: async (...a) => current().track(...a),
91
- query: async (q) => current().query(q),
92
- beaconTag: (...a) => current().beaconTag(...a),
93
- };
94
- })();
95
- function unconfigured() {
96
- return new AnalyticsError('not_configured', `${ANALYTICS_ENV} is not set: declare kf/analytics@1 in kf.project.jsonc for this environment, ` +
97
- `run under \`kf dev\`, or pass the environment to createAnalytics(env)`);
98
- }
@@ -1,35 +0,0 @@
1
- import { type EventRow, type Hint, type RowContext } from './envelope.js';
2
- import type { Analytics } from './types.js';
3
- export interface MemoryAnalyticsOptions {
4
- site?: string;
5
- project?: string;
6
- environment?: string;
7
- /** The host a server event is filed under when it names none. */
8
- hostname?: string;
9
- /** The clock rows are stamped with. */
10
- now?: () => Date;
11
- /** Print each row as the collector would store it: what `kf dev` shows. */
12
- log?: (row: EventRow) => void;
13
- }
14
- export interface MemoryAnalytics extends Analytics {
15
- /** Every row recorded, in order, exactly as the collector would write it. */
16
- readonly events: readonly EventRow[];
17
- /**
18
- * Record a browser beacon, as the collector would on receiving it — to seed
19
- * a test with page views. `ctx` sets what the collector reads off the
20
- * request (`hostname`, `country`, `userAgent`) or overrides the clock.
21
- */
22
- beacon(hint: Hint, ctx?: Partial<Pick<RowContext, 'hostname' | 'country' | 'userAgent' | 'now'>>): void;
23
- clear(): void;
24
- }
25
- /**
26
- * The analytics service in memory, for tests and `kf dev`. It validates and
27
- * builds rows with the collector's own code (`envelope.ts`) and answers
28
- * queries with the compactor's reference (`cubes.ts`), so a test that
29
- * asserts on numbers here asserts on the service's semantics.
30
- *
31
- * One difference, on purpose: there is no nightly job, so an event is
32
- * queryable at once and `through` is the last day that has events, today
33
- * included. The live service never has today.
34
- */
35
- export declare function memoryAnalytics(options?: MemoryAnalyticsOptions): MemoryAnalytics;
@@ -1,66 +0,0 @@
1
- import { cubeRows, runQuery, validateQuery, } from './cubes.js';
2
- import { buildRow, validateHint, } from './envelope.js';
3
- import { AnalyticsError } from './errors.js';
4
- /**
5
- * The analytics service in memory, for tests and `kf dev`. It validates and
6
- * builds rows with the collector's own code (`envelope.ts`) and answers
7
- * queries with the compactor's reference (`cubes.ts`), so a test that
8
- * asserts on numbers here asserts on the service's semantics.
9
- *
10
- * One difference, on purpose: there is no nightly job, so an event is
11
- * queryable at once and `through` is the last day that has events, today
12
- * included. The live service never has today.
13
- */
14
- export function memoryAnalytics(options = {}) {
15
- const site = options.site ?? 'memory';
16
- const project = options.project ?? 'test/project';
17
- const environment = options.environment ?? 'production';
18
- const now = options.now ?? (() => new Date());
19
- const events = [];
20
- const record = (row) => {
21
- events.push(row);
22
- options.log?.(row);
23
- };
24
- const context = (source, ctx = {}) => ({
25
- site,
26
- project,
27
- environment,
28
- source,
29
- hostname: options.hostname ?? 'localhost',
30
- country: null,
31
- userAgent: source === 'beacon' ? 'Mozilla/5.0 (Macintosh)' : '',
32
- now: now(),
33
- ...ctx,
34
- });
35
- return {
36
- events,
37
- async track(event) {
38
- const problem = validateHint(event, 'server');
39
- if (problem)
40
- throw new AnalyticsError('invalid', problem);
41
- record(buildRow(event, context('server')));
42
- },
43
- beacon(hint, ctx) {
44
- const problem = validateHint(hint, 'beacon');
45
- if (problem)
46
- throw new AnalyticsError('invalid', problem);
47
- record(buildRow(hint, context('beacon', ctx)));
48
- },
49
- async query(q) {
50
- const problem = validateQuery(q);
51
- if (problem)
52
- throw new AnalyticsError('invalid', problem);
53
- const env = q.environment ?? environment;
54
- const mine = events.filter((e) => e.environment === env);
55
- const rows = runQuery(cubeRows(q.cube, mine), q);
56
- const through = mine.reduce((max, e) => (max && max > e.day ? max : e.day), null);
57
- return { rows, through };
58
- },
59
- beaconTag() {
60
- return '';
61
- },
62
- clear() {
63
- events.length = 0;
64
- },
65
- };
66
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * `@knowledgefutures/sdk/analytics/testing` — the service in memory, same
3
- * interface, no network. Seed page views with `beacon()`, then assert on
4
- * `query()`: the numbers follow the service's own rules.
5
- */
6
- export { memoryAnalytics, type MemoryAnalytics, type MemoryAnalyticsOptions } from './memory.js';
@@ -1,6 +0,0 @@
1
- /**
2
- * `@knowledgefutures/sdk/analytics/testing` — the service in memory, same
3
- * interface, no network. Seed page views with `beacon()`, then assert on
4
- * `query()`: the numbers follow the service's own rules.
5
- */
6
- export { memoryAnalytics } from './memory.js';