tracwell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/npm/config.d.ts +11 -0
- package/dist/npm/index.d.ts +3 -0
- package/dist/npm/index.js +1 -0
- package/dist/npm/types.d.ts +66 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tracwell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# SDK
|
|
2
|
+
|
|
3
|
+
Browser analytics SDK for page views, custom events, identity, batching, and
|
|
4
|
+
bounded delivery retries.
|
|
5
|
+
|
|
6
|
+
## Script installation
|
|
7
|
+
|
|
8
|
+
The production browser artifact is served from the existing Collector domain:
|
|
9
|
+
|
|
10
|
+
```html
|
|
11
|
+
<script
|
|
12
|
+
defer
|
|
13
|
+
src="https://collect.tracwell.app/script.js"
|
|
14
|
+
data-project-key="tw_live_..."
|
|
15
|
+
data-collection-mode="private"
|
|
16
|
+
data-consent="granted"
|
|
17
|
+
data-respect-do-not-track="true"
|
|
18
|
+
></script>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Loading the script captures the initial page view and exposes the same client
|
|
22
|
+
as `window.tracwell` for custom events:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
window.tracwell?.track("signup_completed", { plan: "starter" });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The script derives `/v1/events` from its own origin, so the same artifact works
|
|
29
|
+
against the loopback Collector during local development. The production bundle
|
|
30
|
+
reports SDK version `0.1.0`, targets ES2022-capable browsers, and has a 4.6 KB
|
|
31
|
+
gzip budget enforced by
|
|
32
|
+
`build:cdn`.
|
|
33
|
+
|
|
34
|
+
## Public API
|
|
35
|
+
|
|
36
|
+
Install the npm package:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pnpm add tracwell
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createTracwell } from "tracwell";
|
|
44
|
+
|
|
45
|
+
const tracwell = createTracwell({
|
|
46
|
+
collectionMode: "private",
|
|
47
|
+
projectKey: "tw_live_...",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
tracwell.track("signup_completed", { plan: "starter" });
|
|
51
|
+
tracwell.identify("customer_123");
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`createTracwell()` starts collection immediately in the browser and captures
|
|
55
|
+
the initial page view by default. It must be called after the document is
|
|
56
|
+
available, not during server rendering.
|
|
57
|
+
|
|
58
|
+
## Responsibilities
|
|
59
|
+
|
|
60
|
+
- Generate stable event and batch IDs before delivery.
|
|
61
|
+
- Capture initial page views, SPA navigation, referrer, UTM, and tagged-link
|
|
62
|
+
context.
|
|
63
|
+
- Expose typed custom-event and identity APIs.
|
|
64
|
+
- Batch only against the limits exported by `@tracwell/contracts`.
|
|
65
|
+
- Retry bounded transient failures without changing event IDs.
|
|
66
|
+
- Respect configured consent and Do Not Track behavior before collection.
|
|
67
|
+
|
|
68
|
+
The SDK must remain browser-only, small, non-blocking, and unable to set trusted
|
|
69
|
+
collector fields. It must not contain secrets or connect to ClickHouse.
|
|
70
|
+
|
|
71
|
+
## Identity and attribution
|
|
72
|
+
|
|
73
|
+
Collection mode is explicit:
|
|
74
|
+
|
|
75
|
+
- `private` is the default. It never reads or writes cookies, `localStorage`,
|
|
76
|
+
or `sessionStorage`; `identify()` reports `IDENTIFY_UNAVAILABLE`. Anonymous
|
|
77
|
+
and session values exist only in memory until the collector replaces them.
|
|
78
|
+
- `product` persists first-party anonymous, session, and optional user identity
|
|
79
|
+
in `localStorage`. Configure consent before collection where required.
|
|
80
|
+
- Private mode rejects an explicit `persistence: "localStorage"` instead of
|
|
81
|
+
silently weakening the project's privacy behavior.
|
|
82
|
+
|
|
83
|
+
- `anonymous_id` is first-party and persists per project unless persistence is
|
|
84
|
+
disabled or identity is reset.
|
|
85
|
+
- `session_id` rotates after 30 minutes without a captured event.
|
|
86
|
+
- A different non-empty UTM campaign starts a new session immediately.
|
|
87
|
+
- The first external referrer and UTM values are preserved for the session.
|
|
88
|
+
- The SDK preserves the absolute referrer. Trusted ClickHouse row mapping uses
|
|
89
|
+
registered-domain/public-suffix parsing so sibling subdomains are not
|
|
90
|
+
reported as external acquisition.
|
|
91
|
+
- `identify()` validates the supplied opaque user ID before associating it with
|
|
92
|
+
future events.
|
|
93
|
+
- Hash-only URL changes do not create duplicate SPA page views.
|
|
94
|
+
- URL fragments are excluded from captured URLs and `path` contains only the
|
|
95
|
+
pathname.
|
|
96
|
+
|
|
97
|
+
Product identity uses `localStorage`, never fingerprinting. If storage is
|
|
98
|
+
disabled or unavailable, the SDK explicitly reports `STORAGE_UNAVAILABLE`
|
|
99
|
+
through `onError` and uses in-memory identity for the page lifetime.
|
|
100
|
+
|
|
101
|
+
## Delivery semantics
|
|
102
|
+
|
|
103
|
+
- Events receive lightweight client preflight validation before entering
|
|
104
|
+
memory. The Collector remains the canonical Zod validation boundary for all
|
|
105
|
+
untrusted browser input.
|
|
106
|
+
- Normal cross-origin delivery uses a safelisted `text/plain` JSON body so it
|
|
107
|
+
does not add a CORS preflight request; the collector still parses and
|
|
108
|
+
validates the body as JSON.
|
|
109
|
+
- Batches default to 25 events and are reduced when necessary to remain within
|
|
110
|
+
the shared 48 KiB limit.
|
|
111
|
+
- Normal delivery reports `accepted` only after a matching `202` receipt
|
|
112
|
+
confirms the batch ID and event count.
|
|
113
|
+
- Network errors, `408`, `429`, and `5xx` responses use bounded in-memory retry
|
|
114
|
+
without changing event or batch IDs.
|
|
115
|
+
- Other `4xx` responses report `DELIVERY_REJECTED` and are not retried.
|
|
116
|
+
- Page-hide and hidden-document delivery prefers `sendBeacon`; a successful
|
|
117
|
+
browser handoff reports `handed_off`, never `accepted`.
|
|
118
|
+
- Exhausted delivery is surfaced through `onError` with the affected event IDs.
|
|
119
|
+
|
|
120
|
+
The default collector endpoint is
|
|
121
|
+
`https://collect.tracwell.app/v1/events`. Production overrides must use HTTPS;
|
|
122
|
+
loopback HTTP is allowed only for local development.
|
|
123
|
+
|
|
124
|
+
## Privacy controls
|
|
125
|
+
|
|
126
|
+
- `consent: "required"` prevents identity creation and collection until
|
|
127
|
+
`setConsent("granted")`.
|
|
128
|
+
- Revoking consent stops listeners, removes queued events, and resets identity.
|
|
129
|
+
- Do Not Track is respected by default and can be disabled explicitly per
|
|
130
|
+
customer configuration.
|
|
131
|
+
- `persistence: "none"` keeps all identifiers in memory.
|
|
132
|
+
- Private collection forces `persistence: "none"` and blocks `identify()`.
|
|
133
|
+
|
|
134
|
+
## Structure
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
src/
|
|
138
|
+
index.ts Stable public exports and browser-only factory
|
|
139
|
+
script.ts Lean self-starting CDN entry and `window.tracwell` exposure
|
|
140
|
+
script-config.ts Strict script-dataset parsing and normalized CDN defaults
|
|
141
|
+
client.ts Consent-aware API and lifecycle orchestration
|
|
142
|
+
config.ts Defaults and configuration validation
|
|
143
|
+
identity.ts Anonymous, session, and user identity state
|
|
144
|
+
attribution.ts First-touch-per-session UTM and referrer capture
|
|
145
|
+
events.ts Context creation and contract-validated events
|
|
146
|
+
transport.ts Size-aware batching, receipts, beacon, and retries
|
|
147
|
+
preflight.ts Small client-side event and receipt checks
|
|
148
|
+
runtime.ts Browser API adapter
|
|
149
|
+
types.ts Public configuration, client, delivery, and error types
|
|
150
|
+
client.test.ts Deterministic SDK behavior and failure tests
|
|
151
|
+
scripts/
|
|
152
|
+
build-cdn.mjs Minified browser build and gzip-size enforcement
|
|
153
|
+
build-npm.mjs Self-contained ESM package build
|
|
154
|
+
smoke-npm.mjs Packed runtime and TypeScript consumer smoke test
|
|
155
|
+
static/
|
|
156
|
+
_headers Cross-origin browser asset headers
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Keep browser globals isolated in `runtime.ts`, keep `index.ts` as the stable
|
|
160
|
+
public API, and test behavior through an injected deterministic runtime.
|
|
161
|
+
|
|
162
|
+
## Dependencies
|
|
163
|
+
|
|
164
|
+
- `@tracwell/contracts` for inferred types and shared limits
|
|
165
|
+
- Browser platform APIs for navigation, lifecycle, and delivery
|
|
166
|
+
- `esbuild` at build time for the minified browser artifact
|
|
167
|
+
|
|
168
|
+
The published npm artifact bundles its runtime contract constants and ships
|
|
169
|
+
generated declarations, so consumers do not need the private contracts
|
|
170
|
+
workspace. Compile-time compatibility checks keep the duplicated public
|
|
171
|
+
property and issue types aligned with the canonical contracts package.
|
|
172
|
+
|
|
173
|
+
The npm package is distributed under the MIT License. Publishing the package
|
|
174
|
+
does not make the private application repository public.
|
|
175
|
+
|
|
176
|
+
## Verify
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
pnpm --filter {packages/sdk} typecheck
|
|
180
|
+
pnpm --filter {packages/sdk} test
|
|
181
|
+
pnpm --filter {packages/sdk} build:cdn
|
|
182
|
+
pnpm --filter {packages/sdk} check:package
|
|
183
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NormalizedConfig, SdkError, TracwellConfig } from "./types";
|
|
2
|
+
export declare const DEFAULT_COLLECTOR_ENDPOINT = "https://collect.tracwell.app/v1/events";
|
|
3
|
+
export declare const DEFAULT_BATCH_SIZE = 25;
|
|
4
|
+
export declare const DEFAULT_FLUSH_INTERVAL_MS = 10000;
|
|
5
|
+
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
6
|
+
export declare const DEFAULT_SESSION_TIMEOUT_MS: number;
|
|
7
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
8
|
+
export declare function normalizeProjectKey(value: string, onError?: (error: SdkError) => void): string;
|
|
9
|
+
export declare function normalizeCollectorEndpoint(value: string, onError?: (error: SdkError) => void): string;
|
|
10
|
+
export declare function normalizeCollectorUrl(endpoint: URL, onError?: (error: SdkError) => void): string;
|
|
11
|
+
export declare function normalizeConfig(config: TracwellConfig): NormalizedConfig;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { DEFAULT_BATCH_SIZE, DEFAULT_COLLECTOR_ENDPOINT, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_SESSION_TIMEOUT_MS, SDK_VERSION, } from "./config";
|
|
2
|
+
export type { ContractIssue, ContractIssueCode, ConsentState, DeliveryReport, EventProperties, JsonPrimitive, JsonValue, SdkError, SdkErrorCode, TracwellClient, TracwellConfig, } from "./types";
|
|
3
|
+
export declare function createTracwell(config: import("./types").TracwellConfig): import("./types").TracwellClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var D="https://collect.tracwell.app/v1/events",k=25,U=1e4,O=2,H=1800*1e3,b="0.1.0";function m(e,t){throw t?.({code:"CONFIG_INVALID",message:e}),new TypeError(e)}function tt(e,t){let n=e.trim();return n.length<20||n.length>128||!/^[A-Za-z0-9_-]+$/u.test(n)?m("projectKey does not match the public project-key contract.",t):n}function et(e,t){let n;try{n=new URL(e)}catch{return m("endpoint must be an absolute HTTPS URL.",t)}return nt(n,t)}function nt(e,t){let n=e.hostname==="localhost"||e.hostname==="127.0.0.1"||e.hostname==="[::1]";return e.protocol!=="https:"&&!n?m("endpoint must use HTTPS unless it uses a loopback host.",t):e.toString()}function E(e,t,n,r,i){return!Number.isSafeInteger(t)||t<n||t>r?m(`${e} must be an integer between ${n} and ${r}.`,i):t}function B(e){let t=tt(e.projectKey,e.onError),n=e.collectionMode??"private",r=et(e.endpoint??D,e.onError);return n==="private"&&e.persistence!==void 0&&e.persistence!=="none"?m('Private collection requires persistence: "none".',e.onError):{autoTrackPageViews:e.autoTrackPageViews??!0,batchSize:E("batchSize",e.batchSize??k,1,50,e.onError),consent:e.consent??"granted",collectionMode:n,endpoint:r,flushIntervalMs:E("flushIntervalMs",e.flushIntervalMs??U,1e3,6e4,e.onError),maxRetries:E("maxRetries",e.maxRetries??O,0,5,e.onError),persistence:n==="private"?"none":e.persistence??"localStorage",projectKey:t,respectDoNotTrack:e.respectDoNotTrack??!0,sessionTimeoutMs:E("sessionTimeoutMs",e.sessionTimeoutMs??H,6e4,1440*60*1e3,e.onError),...e.onDelivery?{onDelivery:e.onDelivery}:{},...e.onError?{onError:e.onError}:{}}}var X=["utm_source","utm_medium","utm_campaign","utm_term","utm_content"];function rt(e){let t=e?.trim();return t||void 0}function it(e){if(e)try{return new URL(e).toString()}catch{return}}function G(e){let t=new URL(e.href),n={},r=it(e.referrer);r&&(n.referrer=r);for(let i of X){let o=rt(t.searchParams.get(i));o&&(n[i]=o)}return n}function A(e){let t=X.map(n=>e[n]??"").join("");return t.replaceAll("","")?t:void 0}var N=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu,_t=/^[A-Za-z][A-Za-z0-9_.:-]*$/u,yt=/[\s\x00-\x1f\x7f]/u,Tt=/[\x00-\x1f\x7f]/u;function L(e){return new TextEncoder().encode(JSON.stringify(e)).byteLength}function R(e){return e.length>0&&e.length<=256&&!yt.test(e)}function Y(e,t=2048){if(e.length>t)return!1;try{return new URL(e).origin.length>0}catch{return!1}}function w(e,t,n){return t>4?!1:typeof e=="string"?e.length<=1024:typeof e=="number"?Number.isFinite(e):e===null||typeof e=="boolean"?!0:Array.isArray(e)?e.length<=50&&e.every(r=>w(r,t+1,n)):Object.entries(e).every(([r,i])=>(n.count+=1,n.count<=100&&r.length>0&&r.length<=128&&!Tt.test(r)&&w(i,t+1,n)))}function K(e){let{context:t}=e;try{if(L(e)>16384)return!1}catch{return!1}return N.test(e.event_id)&&R(e.anonymous_id)&&R(e.session_id)&&(e.user_id===void 0||R(e.user_id))&&(e.event_type!=="event"||e.event_name.length<=100&&_t.test(e.event_name)&&e.event_name!=="identify"&&e.event_name!=="page_view")&&Y(t.url)&&t.path.startsWith("/")&&t.path.length<=1024&&(t.title===void 0||t.title.length<=512)&&(t.referrer===void 0||Y(t.referrer))&&[t.utm_source,t.utm_medium,t.utm_campaign,t.utm_term,t.utm_content].every(n=>n===void 0||n.length>0&&n.length<=256)&&(e.properties===void 0||w(e.properties,0,{count:0}))}function z(e){if(!e||typeof e!="object")return;let t=e;if(!(t.receipt_version!==1||typeof t.receipt_id!="string"||!N.test(t.receipt_id)||typeof t.batch_id!="string"||!N.test(t.batch_id)||!Number.isInteger(t.accepted_events)||(t.accepted_events??0)<1||(t.accepted_events??0)>50||typeof t.accepted_at!="string"||Number.isNaN(Date.parse(t.accepted_at))))return t}function St(e,t){let n=new URL(e.href);return n.hash="",{path:n.pathname,url:n.toString(),...e.title?{title:e.title}:{},...t}}function j(e,t,n,r){let i={schema_version:1,event_id:e.randomUUID(),event_name:n.eventName,event_type:n.eventType,timestamp:new Date(e.now()).toISOString(),anonymous_id:t.anonymousId,session_id:t.sessionId,context:St(e.page(),t.attribution),...n.properties?{properties:n.properties}:{},...n.userId?{user_id:n.userId}:{}};if(!K(i)){r?.({code:"EVENT_INVALID",message:"Event was not queued because it failed the v1 contract."});return}return i}var F=1;function gt(e){if(!e||typeof e!="object")return!1;let t=e;return t.version===F&&typeof t.anonymousId=="string"&&typeof t.sessionId=="string"&&typeof t.lastActivityAt=="number"&&typeof t.attribution=="object"&&t.attribution!==null&&(t.userId===void 0||typeof t.userId=="string")}var _=class{#t;#o;#e;#i;#r;#n;constructor(t,n){this.#e=t,this.#o=n.onError,this.#i=n.sessionTimeoutMs,this.#t=`tracwell:${n.projectKey}:identity`,this.#r=n.persistence==="localStorage"?this.#p(t):void 0}identify(t,n){let r=this.#d(n);return r.userId=t,this.#h(r),this.#s(r)}reset(){if(this.#n=void 0,this.#r)try{this.#r.removeItem(this.#t)}catch(t){this.#a(t)}}snapshot(t){return this.#s(this.#d(t))}#c(t){let n=this.#e.now();return{anonymousId:this.#e.randomUUID(),attribution:t,lastActivityAt:n,sessionId:this.#e.randomUUID(),version:F}}#d(t){let n=this.#e.now(),r=this.#u();if(!r){let a=this.#c(t);return this.#h(a),a}let i=A(r.attribution),o=A(t);return(n-r.lastActivityAt>=this.#i||o!==void 0&&o!==i)&&(r.sessionId=this.#e.randomUUID(),r.attribution=t),r.lastActivityAt=n,this.#h(r),r}#u(){if(this.#n)return this.#n;if(this.#r)try{let t=this.#r.getItem(this.#t);if(!t)return;let n=JSON.parse(t);if(!gt(n)){this.#r.removeItem(this.#t);return}return this.#n=n,n}catch(t){this.#a(t);return}}#p(t){try{return t.storage()}catch(n){this.#a(n);return}}#a(t){this.#o?.({cause:t,code:"STORAGE_UNAVAILABLE",message:"Persistent identity storage is unavailable; using in-memory identity."})}#s(t){return{anonymousId:t.anonymousId,attribution:t.attribution,sessionId:t.sessionId,...t.userId?{userId:t.userId}:{}}}#h(t){if(this.#n=t,!!this.#r)try{this.#r.setItem(this.#t,JSON.stringify(t))}catch(n){this.#a(n)}}};function f(e,t,n,r){return{...r===void 0?{}:{cause:r},code:e,eventIds:n.batch.events.map(i=>i.event_id),message:t}}var y=class{#t;#o;#e;#i;#r;#n=[];constructor(t,n){this.#o=t,this.#t=n}enqueue(t){this.#n.push(t),this.#n.length>=this.#t.batchSize&&this.flush()}clear(){this.#i?.(),this.#i=void 0,this.#e=void 0,this.#n=[]}flush(){return this.#r?this.#r:(this.#r=this.#c().finally(()=>{this.#r=void 0}),this.#r)}flushWithBeacon(){for(this.#i?.(),this.#i=void 0;this.#e||this.#n.length>0;){let t=this.#e??this.#d();if(!t)return;let n=JSON.stringify(t.batch);if(!this.#o.sendBeacon(this.#t.endpoint,n)){this.#t.onError?.(f("DELIVERY_FAILED","Browser lifecycle handoff was rejected by sendBeacon.",t));return}this.#e=void 0,this.#u({batchId:t.batch.batch_id,eventIds:t.batch.events.map(r=>r.event_id),status:"handed_off"})}}async#c(){this.#i?.(),this.#i=void 0;let t=this.#e??this.#d();if(!t)return;await this.#a(t)&&this.#n.length>0&&await this.#c()}#d(){if(this.#n.length===0)return;let t=this.#n.slice(0,this.#t.batchSize),n;for(;t.length>0;){let r={batch_version:1,batch_id:this.#o.randomUUID(),project_key:this.#t.projectKey,sent_at:new Date(this.#o.now()).toISOString(),sdk:{name:"tracwell-browser",version:b},events:t};if(L(r)<=49152){n=r;break}t.pop()}if(!n){let r=this.#n.shift();r&&this.#t.onError?.({code:"EVENT_INVALID",eventIds:[r.event_id],message:"Event cannot fit in a valid browser batch."});return}return this.#n.splice(0,n.events.length),this.#e={attempt:0,batch:n},this.#e}#u(t){this.#t.onDelivery?.(t)}#p(t){let n=250*2**(t.attempt-1);this.#i=this.#o.setTimeout(()=>{this.#i=void 0,this.flush()},n)}async#a(t){let n;try{n=await this.#o.fetch(this.#t.endpoint,JSON.stringify(t.batch),"text/plain;charset=UTF-8")}catch(o){return this.#e!==t?!1:this.#s(t,o)}if(n.status!==202)return n.status===408||n.status===429||n.status>=500?this.#s(t,new Error(`Collector returned HTTP ${n.status}.`)):(this.#e===t&&(this.#e=void 0),this.#t.onError?.(f("DELIVERY_REJECTED",`Collector rejected the batch with non-retryable HTTP ${n.status}.`,t)),!1);let r;try{r=await n.json()}catch(o){return this.#t.onError?.(f("RECEIPT_INVALID","Collector returned an unreadable acceptance receipt.",t,o)),this.#s(t,o)}let i=z(r);return!i||i.batch_id!==t.batch.batch_id||i.accepted_events!==t.batch.events.length?(this.#t.onError?.(f("RECEIPT_INVALID","Collector receipt does not match the submitted batch.",t)),this.#s(t,new Error("Acceptance receipt mismatch."))):(this.#e===t&&(this.#e=void 0),this.#u({batchId:t.batch.batch_id,eventIds:t.batch.events.map(o=>o.event_id),receiptId:i.receipt_id,status:"accepted"}),!0)}#s(t,n){return this.#e!==t?!1:(t.attempt+=1,t.attempt<=this.#t.maxRetries?(this.#t.onError?.(f("DELIVERY_FAILED","Event batch delivery failed and is scheduled for bounded retry.",t,n)),this.#p(t),!1):(this.#e=void 0,this.#t.onError?.(f("DELIVERY_EXHAUSTED","Event batch delivery exhausted the configured retry limit.",t,n)),!1))}};function J(e,t){let n=new _(e,t),r=new y(e,t),i=t.consent,o,u=!1,p=!1,a=[];function T(){return i==="granted"&&!(t.respectDoNotTrack&&e.doNotTrackEnabled())}function S(s,c,d,h){if(p||!T())return;let V=G(e.page()),M=n.snapshot(V),l=h??M.userId,W=s==="identify"&&l?{eventName:"identify",eventType:"identify",...d?{properties:d}:{},userId:l}:s==="pageview"?{eventName:"page_view",eventType:"pageview",...d?{properties:d}:{},...l?{userId:l}:{}}:{eventName:c,eventType:"event",...d?{properties:d}:{},...l?{userId:l}:{}},v=j(e,M,W,t.onError);if(v)return s==="identify"&&h&&n.identify(h,V),r.enqueue(v),v.event_id}function g(s){if(!T())return;let c=new URL(e.page().href);c.hash="";let d=c.toString();if(d===o)return;let h=S("pageview","page_view",s);return h&&(o=d),h}function P(){a.forEach(s=>s()),a=[],u=!1}function x(){u||p||!T()||(u=!0,a=[e.addPageHideListener(()=>r.flushWithBeacon()),e.addVisibilityHiddenListener(()=>r.flushWithBeacon()),e.setInterval(()=>{r.flush()},t.flushIntervalMs)],t.autoTrackPageViews&&(a.push(e.subscribeToNavigation(()=>{g()})),g()))}return x(),{flush(){return r.flush()},identify(s,c){if(t.collectionMode==="private"){t.onError?.({code:"IDENTIFY_UNAVAILABLE",message:"identify() is unavailable in Private collection mode."});return}return S("identify","identify",c,s)},page:g,reset(){n.reset(),o=void 0},setConsent(s){if(i=s,s==="denied"){P(),r.clear(),n.reset(),o=void 0;return}x()},async shutdown(){p||(P(),await r.flush(),p=!0)},track(s,c){return S("event",s,c)}}}function $(e,t){return window.addEventListener(e,t),()=>window.removeEventListener(e,t)}function q(){return{addPageHideListener(e){return $("pagehide",e)},addVisibilityHiddenListener(e){let t=()=>{document.visibilityState==="hidden"&&e()};return document.addEventListener("visibilitychange",t),()=>document.removeEventListener("visibilitychange",t)},doNotTrackEnabled(){return navigator.doNotTrack==="1"},async fetch(e,t,n){return fetch(e,{body:t,credentials:"omit",headers:{"content-type":n},keepalive:!0,method:"POST"})},now(){return Date.now()},page(){return{href:window.location.href,referrer:document.referrer,title:document.title}},randomUUID(){return crypto.randomUUID()},sendBeacon(e,t){return navigator.sendBeacon(e,t)},setInterval(e,t){let n=window.setInterval(e,t);return()=>window.clearInterval(n)},setTimeout(e,t){let n=window.setTimeout(e,t);return()=>window.clearTimeout(n)},storage(){return window.localStorage},subscribeToNavigation(e){let t=!0,n=history.pushState,r=history.replaceState,i=()=>{t&&queueMicrotask(e)},o=function(...a){n.apply(history,a),i()},u=function(...a){r.apply(history,a),i()};history.pushState=o,history.replaceState=u;let p=$("popstate",e);return()=>{t=!1,p(),history.pushState===o&&(history.pushState=n),history.replaceState===u&&(history.replaceState=r)}}}}function Ft(e){if(typeof window>"u"||typeof document>"u"||typeof navigator>"u")throw new Error("createTracwell must be called in a browser after the document is available.");return J(q(),B(e))}export{k as DEFAULT_BATCH_SIZE,D as DEFAULT_COLLECTOR_ENDPOINT,U as DEFAULT_FLUSH_INTERVAL_MS,O as DEFAULT_MAX_RETRIES,H as DEFAULT_SESSION_TIMEOUT_MS,b as SDK_VERSION,Ft as createTracwell};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type JsonPrimitive = boolean | number | string | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export type EventProperties = Record<string, JsonValue>;
|
|
6
|
+
export type ContractIssueCode = "DUPLICATE_EVENT_ID" | "INVALID_FORMAT" | "INVALID_PAYLOAD" | "INVALID_TYPE" | "INVALID_VALUE" | "PAYLOAD_TOO_LARGE" | "PROPERTY_COUNT_EXCEEDED" | "PROPERTY_DEPTH_EXCEEDED" | "RESERVED_EVENT_NAME" | "TIMESTAMP_TOO_FAR_IN_FUTURE" | "TIMESTAMP_TOO_OLD" | "UNKNOWN_FIELD" | "VALUE_TOO_LARGE" | "VALUE_TOO_SMALL";
|
|
7
|
+
export interface ContractIssue {
|
|
8
|
+
code: ContractIssueCode;
|
|
9
|
+
path: (number | string)[];
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
export type ConsentState = "denied" | "granted" | "required";
|
|
13
|
+
export type CollectionMode = "private" | "product";
|
|
14
|
+
export type SdkErrorCode = "CONFIG_INVALID" | "DELIVERY_EXHAUSTED" | "DELIVERY_FAILED" | "DELIVERY_REJECTED" | "EVENT_INVALID" | "IDENTIFY_UNAVAILABLE" | "RECEIPT_INVALID" | "STORAGE_UNAVAILABLE";
|
|
15
|
+
export interface SdkError {
|
|
16
|
+
code: SdkErrorCode;
|
|
17
|
+
message: string;
|
|
18
|
+
cause?: unknown;
|
|
19
|
+
eventIds?: string[];
|
|
20
|
+
issues?: ContractIssue[];
|
|
21
|
+
}
|
|
22
|
+
export interface DeliveryReport {
|
|
23
|
+
batchId: string;
|
|
24
|
+
eventIds: string[];
|
|
25
|
+
status: "accepted" | "handed_off";
|
|
26
|
+
receiptId?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TracwellConfig {
|
|
29
|
+
projectKey: string;
|
|
30
|
+
collectionMode?: CollectionMode;
|
|
31
|
+
endpoint?: string;
|
|
32
|
+
autoTrackPageViews?: boolean;
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
consent?: ConsentState;
|
|
35
|
+
flushIntervalMs?: number;
|
|
36
|
+
maxRetries?: number;
|
|
37
|
+
persistence?: "localStorage" | "none";
|
|
38
|
+
respectDoNotTrack?: boolean;
|
|
39
|
+
sessionTimeoutMs?: number;
|
|
40
|
+
onDelivery?: (report: DeliveryReport) => void;
|
|
41
|
+
onError?: (error: SdkError) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface TracwellClient {
|
|
44
|
+
flush(): Promise<void>;
|
|
45
|
+
identify(userId: string, properties?: EventProperties): string | undefined;
|
|
46
|
+
page(properties?: EventProperties): string | undefined;
|
|
47
|
+
reset(): void;
|
|
48
|
+
setConsent(consent: Exclude<ConsentState, "required">): void;
|
|
49
|
+
shutdown(): Promise<void>;
|
|
50
|
+
track(eventName: string, properties?: EventProperties): string | undefined;
|
|
51
|
+
}
|
|
52
|
+
export interface NormalizedConfig {
|
|
53
|
+
autoTrackPageViews: boolean;
|
|
54
|
+
batchSize: number;
|
|
55
|
+
consent: ConsentState;
|
|
56
|
+
collectionMode: CollectionMode;
|
|
57
|
+
endpoint: string;
|
|
58
|
+
flushIntervalMs: number;
|
|
59
|
+
maxRetries: number;
|
|
60
|
+
persistence: "localStorage" | "none";
|
|
61
|
+
projectKey: string;
|
|
62
|
+
respectDoNotTrack: boolean;
|
|
63
|
+
sessionTimeoutMs: number;
|
|
64
|
+
onDelivery?: (report: DeliveryReport) => void;
|
|
65
|
+
onError?: (error: SdkError) => void;
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tracwell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Small, privacy-aware browser analytics SDK for Tracwell",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/npm/index.js",
|
|
9
|
+
"module": "./dist/npm/index.js",
|
|
10
|
+
"types": "./dist/npm/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/npm/index.d.ts",
|
|
14
|
+
"import": "./dist/npm/index.js",
|
|
15
|
+
"default": "./dist/npm/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/npm/index.js",
|
|
20
|
+
"dist/npm/index.d.ts",
|
|
21
|
+
"dist/npm/config.d.ts",
|
|
22
|
+
"dist/npm/types.d.ts",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"analytics",
|
|
28
|
+
"browser",
|
|
29
|
+
"privacy",
|
|
30
|
+
"tracwell",
|
|
31
|
+
"web-analytics"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"esbuild": "0.28.1",
|
|
38
|
+
"publint": "0.3.24",
|
|
39
|
+
"vitest": "4.1.10",
|
|
40
|
+
"@tracwell/contracts": "0.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "pnpm build:npm && pnpm build:cdn",
|
|
44
|
+
"build:cdn": "node scripts/build-cdn.mjs",
|
|
45
|
+
"build:npm": "node scripts/build-npm.mjs && tsc -p tsconfig.build.json",
|
|
46
|
+
"check:package": "pnpm build:npm && publint && node scripts/smoke-npm.mjs",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
49
|
+
}
|
|
50
|
+
}
|