@anyreplay/browser 0.1.1 → 0.1.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.cts CHANGED
@@ -40,14 +40,15 @@ declare class ConfigError extends Error {
40
40
  declare function resolveOptions(input: AnyReplayOptions): ResolvedOptions;
41
41
 
42
42
  type RecorderStatus = 'idle' | 'awaiting-consent' | 'recording' | 'sampled-out' | 'stopped';
43
+ interface IdentifyTraits {
44
+ userId?: string;
45
+ email?: string;
46
+ }
43
47
  interface RecorderHandle {
44
48
  status: () => RecorderStatus;
45
49
  sessionId: () => string | null;
46
50
  consent: (granted: boolean) => void;
47
- identify: (traits: {
48
- userId?: string;
49
- email?: string;
50
- }) => void;
51
+ identify: (traits: IdentifyTraits) => void;
51
52
  /**
52
53
  * Tags this moment of the recording so it shows on the player's timeline.
53
54
  *
@@ -199,6 +200,18 @@ interface Identity {
199
200
  * id resets after a period of inactivity.
200
201
  */
201
202
  declare function resolveIdentity(local: Storage, session: Storage, now?: number): Identity;
203
+ /**
204
+ * Removes everything the recorder has stored in this browser.
205
+ *
206
+ * Called when consent is refused. A refusal has to undo an earlier acceptance,
207
+ * not merely stop the current page: the visitor id written last week is
208
+ * exactly what the visitor is now saying no to, and leaving it in place would
209
+ * make "we store nothing without consent" untrue for as long as it sat there.
210
+ * The cool-down goes too — it is a fact about a session that no longer exists.
211
+ * Removing a key that is not there creates nothing, so this is safe to call
212
+ * before anything was ever written.
213
+ */
214
+ declare function forgetVisitor(local: Storage, session: Storage): void;
202
215
 
203
216
  /**
204
217
  * Custom events: the moments a page tags with `track()`.
@@ -225,4 +238,4 @@ interface TrackPayload {
225
238
  */
226
239
  declare function validateTrack(name: unknown, properties: unknown): TrackPayload | string;
227
240
 
228
- export { type AnyReplayOptions, ConfigError, DEFAULTS, EVENT_NAME_PATTERN, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, type SessionMeta, TRACK_TAG, type TrackPayload, Transport, createRecorder, hashToUnitInterval, isSampledIn, resolveIdentity, resolveOptions, safeStorage, uuidv4, validateTrack };
241
+ export { type AnyReplayOptions, ConfigError, DEFAULTS, EVENT_NAME_PATTERN, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, type SessionMeta, TRACK_TAG, type TrackPayload, Transport, createRecorder, forgetVisitor, hashToUnitInterval, isSampledIn, resolveIdentity, resolveOptions, safeStorage, uuidv4, validateTrack };
package/dist/index.d.ts CHANGED
@@ -40,14 +40,15 @@ declare class ConfigError extends Error {
40
40
  declare function resolveOptions(input: AnyReplayOptions): ResolvedOptions;
41
41
 
42
42
  type RecorderStatus = 'idle' | 'awaiting-consent' | 'recording' | 'sampled-out' | 'stopped';
43
+ interface IdentifyTraits {
44
+ userId?: string;
45
+ email?: string;
46
+ }
43
47
  interface RecorderHandle {
44
48
  status: () => RecorderStatus;
45
49
  sessionId: () => string | null;
46
50
  consent: (granted: boolean) => void;
47
- identify: (traits: {
48
- userId?: string;
49
- email?: string;
50
- }) => void;
51
+ identify: (traits: IdentifyTraits) => void;
51
52
  /**
52
53
  * Tags this moment of the recording so it shows on the player's timeline.
53
54
  *
@@ -199,6 +200,18 @@ interface Identity {
199
200
  * id resets after a period of inactivity.
200
201
  */
201
202
  declare function resolveIdentity(local: Storage, session: Storage, now?: number): Identity;
203
+ /**
204
+ * Removes everything the recorder has stored in this browser.
205
+ *
206
+ * Called when consent is refused. A refusal has to undo an earlier acceptance,
207
+ * not merely stop the current page: the visitor id written last week is
208
+ * exactly what the visitor is now saying no to, and leaving it in place would
209
+ * make "we store nothing without consent" untrue for as long as it sat there.
210
+ * The cool-down goes too — it is a fact about a session that no longer exists.
211
+ * Removing a key that is not there creates nothing, so this is safe to call
212
+ * before anything was ever written.
213
+ */
214
+ declare function forgetVisitor(local: Storage, session: Storage): void;
202
215
 
203
216
  /**
204
217
  * Custom events: the moments a page tags with `track()`.
@@ -225,4 +238,4 @@ interface TrackPayload {
225
238
  */
226
239
  declare function validateTrack(name: unknown, properties: unknown): TrackPayload | string;
227
240
 
228
- export { type AnyReplayOptions, ConfigError, DEFAULTS, EVENT_NAME_PATTERN, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, type SessionMeta, TRACK_TAG, type TrackPayload, Transport, createRecorder, hashToUnitInterval, isSampledIn, resolveIdentity, resolveOptions, safeStorage, uuidv4, validateTrack };
241
+ export { type AnyReplayOptions, ConfigError, DEFAULTS, EVENT_NAME_PATTERN, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, type SessionMeta, TRACK_TAG, type TrackPayload, Transport, createRecorder, forgetVisitor, hashToUnitInterval, isSampledIn, resolveIdentity, resolveOptions, safeStorage, uuidv4, validateTrack };
package/dist/index.js CHANGED
@@ -4007,6 +4007,10 @@ function reserveSeq(session, next) {
4007
4007
  function touchSession(session, now = Date.now()) {
4008
4008
  session.set(SESSION_TS_KEY, String(now));
4009
4009
  }
4010
+ function forgetVisitor(local, session) {
4011
+ local.remove(VISITOR_KEY);
4012
+ for (const key of [SESSION_KEY, SESSION_TS_KEY, SEQ_KEY, PAGE_KEY, COOLDOWN_KEY]) session.remove(key);
4013
+ }
4010
4014
 
4011
4015
  // src/recorder.ts
4012
4016
  var RAGE_CLICK_COUNT = 3;
@@ -4017,37 +4021,22 @@ function createRecorder(rawOptions) {
4017
4021
  const options = resolveOptions(rawOptions);
4018
4022
  const local = safeStorage(() => globalThis.localStorage);
4019
4023
  const session = safeStorage(() => globalThis.sessionStorage);
4020
- const identity = resolveIdentity(local, session);
4021
4024
  let status = "idle";
4025
+ let identity;
4026
+ let transport;
4022
4027
  let stopRecording;
4023
- let pageCount = identity.pageCount;
4024
- if (!isSampledIn(identity.visitorId, options.sampleRate)) {
4025
- return inertHandle("sampled-out", identity.sessionId);
4026
- }
4027
- if (inCooldown(session)) {
4028
- if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
4029
- return inertHandle("stopped", identity.sessionId);
4030
- }
4031
- const transport = new Transport(
4032
- options,
4033
- {
4034
- projectKey: options.projectKey,
4035
- sessionId: identity.sessionId,
4036
- visitorId: identity.visitorId,
4037
- initialSeq: identity.nextSeq
4038
- },
4039
- {
4040
- onSeqReserved: (next) => reserveSeq(session, next),
4041
- onStopped: (reason) => {
4042
- status = "stopped";
4043
- stopRecording == null ? void 0 : stopRecording();
4044
- stopRecording = void 0;
4045
- if (reason === "rejected_402") startCooldown(session);
4046
- if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
4047
- }
4048
- }
4049
- );
4050
- const collectMeta = () => ({
4028
+ let pageCount = 0;
4029
+ let pendingTraits;
4030
+ const sendIdentity = (who, traits) => {
4031
+ void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
4032
+ method: "POST",
4033
+ headers: { "Content-Type": "application/json" },
4034
+ credentials: "omit",
4035
+ body: JSON.stringify({ projectKey: options.projectKey, sessionId: who.sessionId, ...traits })
4036
+ }).catch(() => {
4037
+ });
4038
+ };
4039
+ const collectMeta = (who) => ({
4051
4040
  startedAt: Date.now(),
4052
4041
  url: safeLocation(),
4053
4042
  lang: typeof navigator !== "undefined" ? navigator.language : void 0,
@@ -4057,10 +4046,10 @@ function createRecorder(rawOptions) {
4057
4046
  referrer: typeof document !== "undefined" ? document.referrer || void 0 : void 0,
4058
4047
  // The cap is a decision about starting a session, so it goes only where
4059
4048
  // ingest makes that decision: chunk 0. A resumed page starts later.
4060
- ...options.maxSessionsPerMonth !== void 0 && identity.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
4049
+ ...options.maxSessionsPerMonth !== void 0 && who.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
4061
4050
  });
4062
4051
  const recentClicks = [];
4063
- const noteInteraction = (event) => {
4052
+ const noteInteraction = (event, channel) => {
4064
4053
  var _a;
4065
4054
  const data = event.data;
4066
4055
  if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
@@ -4072,7 +4061,7 @@ function createRecorder(rawOptions) {
4072
4061
  const clustered = recentClicks.every(
4073
4062
  (c) => Math.abs(c.x - first.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - first.y) < RAGE_CLICK_RADIUS_PX
4074
4063
  );
4075
- if (clustered) transport.mergeFlags({ hasRageClick: true });
4064
+ if (clustered) channel.mergeFlags({ hasRageClick: true });
4076
4065
  }
4077
4066
  };
4078
4067
  const pending = [];
@@ -4100,7 +4089,7 @@ function createRecorder(rawOptions) {
4100
4089
  }
4101
4090
  };
4102
4091
  const emitCustom = (tag, payload) => {
4103
- if (status === "stopped") return;
4092
+ if (status === "stopped" || status === "sampled-out") return;
4104
4093
  if (status === "recording") {
4105
4094
  try {
4106
4095
  record.addCustomEvent(tag, payload);
@@ -4114,28 +4103,61 @@ function createRecorder(rawOptions) {
4114
4103
  }
4115
4104
  pending.push({ tag, payload });
4116
4105
  };
4117
- let lastUrl = safeLocation();
4118
- const noteNavigation = () => {
4106
+ let lastUrl;
4107
+ const noteNavigation = (channel) => {
4119
4108
  const url = safeLocation();
4120
4109
  if (!url || url === lastUrl) return;
4121
4110
  lastUrl = url;
4122
4111
  pageCount += 1;
4123
4112
  notePageCount(session, pageCount);
4124
- transport.mergeFlags({ pageCount });
4113
+ channel.mergeFlags({ pageCount });
4125
4114
  emitCustom(NAVIGATE_TAG, { url, title: typeof document !== "undefined" ? document.title : "" });
4126
4115
  };
4127
4116
  const begin = () => {
4128
- if (status === "recording" || status === "stopped") return;
4117
+ if (status !== "idle" && status !== "awaiting-consent") return;
4118
+ const who = resolveIdentity(local, session);
4119
+ identity = who;
4120
+ pageCount = who.pageCount;
4121
+ if (!isSampledIn(who.visitorId, options.sampleRate)) {
4122
+ status = "sampled-out";
4123
+ return;
4124
+ }
4125
+ if (inCooldown(session)) {
4126
+ status = "stopped";
4127
+ if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
4128
+ return;
4129
+ }
4130
+ const channel = new Transport(
4131
+ options,
4132
+ {
4133
+ projectKey: options.projectKey,
4134
+ sessionId: who.sessionId,
4135
+ visitorId: who.visitorId,
4136
+ initialSeq: who.nextSeq
4137
+ },
4138
+ {
4139
+ onSeqReserved: (next) => reserveSeq(session, next),
4140
+ onStopped: (reason) => {
4141
+ status = "stopped";
4142
+ stopRecording == null ? void 0 : stopRecording();
4143
+ stopRecording = void 0;
4144
+ if (reason === "rejected_402") startCooldown(session);
4145
+ if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
4146
+ }
4147
+ }
4148
+ );
4149
+ transport = channel;
4129
4150
  status = "recording";
4130
- transport.setMeta(collectMeta());
4131
- transport.mergeFlags({ pageCount });
4132
- transport.start();
4151
+ lastUrl = safeLocation();
4152
+ channel.setMeta(collectMeta(who));
4153
+ channel.mergeFlags({ pageCount });
4154
+ channel.start();
4133
4155
  stopRecording = record({
4134
4156
  emit: (event) => {
4135
4157
  const typed = event;
4136
- noteInteraction(typed);
4158
+ noteInteraction(typed, channel);
4137
4159
  touchSession(session, typed.timestamp);
4138
- transport.push(typed);
4160
+ channel.push(typed);
4139
4161
  drainPending();
4140
4162
  },
4141
4163
  maskAllInputs: options.maskAllInputs,
@@ -4148,29 +4170,33 @@ function createRecorder(rawOptions) {
4148
4170
  collectFonts: false
4149
4171
  });
4150
4172
  drainPending();
4151
- attachLifecycleHooks();
4173
+ if (pendingTraits) {
4174
+ sendIdentity(who, pendingTraits);
4175
+ pendingTraits = void 0;
4176
+ }
4177
+ attachLifecycleHooks(channel);
4152
4178
  };
4153
- const attachLifecycleHooks = () => {
4179
+ const attachLifecycleHooks = (channel) => {
4154
4180
  if (typeof window === "undefined") return;
4155
4181
  window.addEventListener("pagehide", () => {
4156
- transport.flushWithBeacon();
4182
+ channel.flushWithBeacon();
4157
4183
  }, { capture: true });
4158
4184
  document.addEventListener("visibilitychange", () => {
4159
- if (document.visibilityState === "hidden") transport.flushWithBeacon();
4185
+ if (document.visibilityState === "hidden") channel.flushWithBeacon();
4160
4186
  });
4161
- window.addEventListener("error", () => transport.mergeFlags({ hasError: true }));
4162
- window.addEventListener("unhandledrejection", () => transport.mergeFlags({ hasError: true }));
4187
+ window.addEventListener("error", () => channel.mergeFlags({ hasError: true }));
4188
+ window.addEventListener("unhandledrejection", () => channel.mergeFlags({ hasError: true }));
4163
4189
  const patch2 = (method) => {
4164
4190
  const original = history[method].bind(history);
4165
4191
  history[method] = function patched(...args) {
4166
4192
  const result = original(...args);
4167
- noteNavigation();
4193
+ noteNavigation(channel);
4168
4194
  return result;
4169
4195
  };
4170
4196
  };
4171
4197
  patch2("pushState");
4172
4198
  patch2("replaceState");
4173
- window.addEventListener("popstate", noteNavigation);
4199
+ window.addEventListener("popstate", () => noteNavigation(channel));
4174
4200
  };
4175
4201
  if (options.requireConsent) {
4176
4202
  status = "awaiting-consent";
@@ -4179,24 +4205,28 @@ function createRecorder(rawOptions) {
4179
4205
  }
4180
4206
  return {
4181
4207
  status: () => status,
4182
- sessionId: () => identity.sessionId,
4208
+ sessionId: () => {
4209
+ var _a;
4210
+ return (_a = identity == null ? void 0 : identity.sessionId) != null ? _a : null;
4211
+ },
4183
4212
  consent: (granted) => {
4184
- if (granted) begin();
4185
- else transport.stop("consent_withdrawn");
4213
+ if (granted) {
4214
+ begin();
4215
+ return;
4216
+ }
4217
+ transport == null ? void 0 : transport.stop("consent_withdrawn");
4218
+ if (status === "idle" || status === "awaiting-consent") status = "stopped";
4219
+ pendingTraits = void 0;
4220
+ forgetVisitor(local, session);
4186
4221
  },
4187
4222
  identify: (traits) => {
4188
4223
  if (!traits.userId && !traits.email) return;
4189
- void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
4190
- method: "POST",
4191
- headers: { "Content-Type": "application/json" },
4192
- credentials: "omit",
4193
- body: JSON.stringify({
4194
- projectKey: options.projectKey,
4195
- sessionId: identity.sessionId,
4196
- ...traits
4197
- })
4198
- }).catch(() => {
4199
- });
4224
+ if (identity && transport) {
4225
+ sendIdentity(identity, traits);
4226
+ return;
4227
+ }
4228
+ if (status === "idle" || status === "awaiting-consent") pendingTraits = traits;
4229
+ else if (options.debug) console.warn("[anyreplay] identify() ignored: not recording");
4200
4230
  },
4201
4231
  track: (name, properties) => {
4202
4232
  const payload = validateTrack(name, properties);
@@ -4206,8 +4236,15 @@ function createRecorder(rawOptions) {
4206
4236
  }
4207
4237
  emitCustom(TRACK_TAG, payload);
4208
4238
  },
4209
- stop: () => transport.stop("stopped_by_page"),
4210
- flush: () => transport.flush()
4239
+ stop: () => {
4240
+ transport == null ? void 0 : transport.stop("stopped_by_page");
4241
+ if (status === "idle" || status === "awaiting-consent") status = "stopped";
4242
+ pendingTraits = void 0;
4243
+ },
4244
+ flush: () => {
4245
+ var _a;
4246
+ return (_a = transport == null ? void 0 : transport.flush()) != null ? _a : Promise.resolve();
4247
+ }
4211
4248
  };
4212
4249
  }
4213
4250
  function safeLocation() {
@@ -4217,22 +4254,6 @@ function safeLocation() {
4217
4254
  return void 0;
4218
4255
  }
4219
4256
  }
4220
- function inertHandle(status, sessionId) {
4221
- return {
4222
- status: () => status,
4223
- sessionId: () => sessionId,
4224
- consent: () => {
4225
- },
4226
- identify: () => {
4227
- },
4228
- track: () => {
4229
- },
4230
- stop: () => {
4231
- },
4232
- flush: async () => {
4233
- }
4234
- };
4235
- }
4236
4257
  export {
4237
4258
  ConfigError,
4238
4259
  DEFAULTS,
@@ -4242,6 +4263,7 @@ export {
4242
4263
  TRACK_TAG,
4243
4264
  Transport,
4244
4265
  createRecorder,
4266
+ forgetVisitor,
4245
4267
  hashToUnitInterval,
4246
4268
  isSampledIn,
4247
4269
  resolveIdentity,