@creezio/observability 0.22.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.
Files changed (209) hide show
  1. package/README.md +328 -0
  2. package/dist/.creezio-src-hash.json +4 -0
  3. package/dist/api-endpoints/http-routes.d.ts +11 -0
  4. package/dist/api-endpoints/http-routes.d.ts.map +1 -0
  5. package/dist/api-endpoints/http-routes.js +11 -0
  6. package/dist/api-endpoints/http-routes.js.map +1 -0
  7. package/dist/api-endpoints/index.d.ts +4 -0
  8. package/dist/api-endpoints/index.d.ts.map +1 -0
  9. package/dist/api-endpoints/index.js +3 -0
  10. package/dist/api-endpoints/index.js.map +1 -0
  11. package/dist/api-endpoints/registry.d.ts +66 -0
  12. package/dist/api-endpoints/registry.d.ts.map +1 -0
  13. package/dist/api-endpoints/registry.js +102 -0
  14. package/dist/api-endpoints/registry.js.map +1 -0
  15. package/dist/api-mount.d.ts +7 -0
  16. package/dist/api-mount.d.ts.map +1 -0
  17. package/dist/api-mount.js +114 -0
  18. package/dist/api-mount.js.map +1 -0
  19. package/dist/helpers.d.ts +23 -0
  20. package/dist/helpers.d.ts.map +1 -0
  21. package/dist/helpers.js +36 -0
  22. package/dist/helpers.js.map +1 -0
  23. package/dist/index.d.ts +35 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +24 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/memory-store.d.ts +3 -0
  28. package/dist/memory-store.d.ts.map +1 -0
  29. package/dist/memory-store.js +113 -0
  30. package/dist/memory-store.js.map +1 -0
  31. package/dist/ops/emit.d.ts +8 -0
  32. package/dist/ops/emit.d.ts.map +1 -0
  33. package/dist/ops/emit.js +15 -0
  34. package/dist/ops/emit.js.map +1 -0
  35. package/dist/ops/fleet-activity.d.ts +50 -0
  36. package/dist/ops/fleet-activity.d.ts.map +1 -0
  37. package/dist/ops/fleet-activity.js +123 -0
  38. package/dist/ops/fleet-activity.js.map +1 -0
  39. package/dist/ops/fleet-agent.d.ts +102 -0
  40. package/dist/ops/fleet-agent.d.ts.map +1 -0
  41. package/dist/ops/fleet-agent.js +352 -0
  42. package/dist/ops/fleet-agent.js.map +1 -0
  43. package/dist/ops/fleet-samples.d.ts +33 -0
  44. package/dist/ops/fleet-samples.d.ts.map +1 -0
  45. package/dist/ops/fleet-samples.js +223 -0
  46. package/dist/ops/fleet-samples.js.map +1 -0
  47. package/dist/ops/journal.d.ts +56 -0
  48. package/dist/ops/journal.d.ts.map +1 -0
  49. package/dist/ops/journal.js +307 -0
  50. package/dist/ops/journal.js.map +1 -0
  51. package/dist/ops/rules.d.ts +16 -0
  52. package/dist/ops/rules.d.ts.map +1 -0
  53. package/dist/ops/rules.js +98 -0
  54. package/dist/ops/rules.js.map +1 -0
  55. package/dist/ops/types.d.ts +69 -0
  56. package/dist/ops/types.d.ts.map +1 -0
  57. package/dist/ops/types.js +119 -0
  58. package/dist/ops/types.js.map +1 -0
  59. package/dist/request-logs/config.d.ts +19 -0
  60. package/dist/request-logs/config.d.ts.map +1 -0
  61. package/dist/request-logs/config.js +36 -0
  62. package/dist/request-logs/config.js.map +1 -0
  63. package/dist/request-logs/http-routes.d.ts +8 -0
  64. package/dist/request-logs/http-routes.d.ts.map +1 -0
  65. package/dist/request-logs/http-routes.js +29 -0
  66. package/dist/request-logs/http-routes.js.map +1 -0
  67. package/dist/request-logs/index.d.ts +6 -0
  68. package/dist/request-logs/index.d.ts.map +1 -0
  69. package/dist/request-logs/index.js +5 -0
  70. package/dist/request-logs/index.js.map +1 -0
  71. package/dist/request-logs/middleware.d.ts +11 -0
  72. package/dist/request-logs/middleware.d.ts.map +1 -0
  73. package/dist/request-logs/middleware.js +198 -0
  74. package/dist/request-logs/middleware.js.map +1 -0
  75. package/dist/request-logs/request-logs.d.ts +60 -0
  76. package/dist/request-logs/request-logs.d.ts.map +1 -0
  77. package/dist/request-logs/request-logs.js +260 -0
  78. package/dist/request-logs/request-logs.js.map +1 -0
  79. package/dist/schema.d.ts +3 -0
  80. package/dist/schema.d.ts.map +1 -0
  81. package/dist/schema.js +21 -0
  82. package/dist/schema.js.map +1 -0
  83. package/dist/sqlite-driver.d.ts +16 -0
  84. package/dist/sqlite-driver.d.ts.map +1 -0
  85. package/dist/sqlite-driver.js +15 -0
  86. package/dist/sqlite-driver.js.map +1 -0
  87. package/dist/sqlite-store.d.ts +12 -0
  88. package/dist/sqlite-store.d.ts.map +1 -0
  89. package/dist/sqlite-store.js +176 -0
  90. package/dist/sqlite-store.js.map +1 -0
  91. package/dist/types.d.ts +67 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +9 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/usage/adapters.d.ts +31 -0
  96. package/dist/usage/adapters.d.ts.map +1 -0
  97. package/dist/usage/adapters.js +37 -0
  98. package/dist/usage/adapters.js.map +1 -0
  99. package/dist/usage/http-routes.d.ts +23 -0
  100. package/dist/usage/http-routes.d.ts.map +1 -0
  101. package/dist/usage/http-routes.js +150 -0
  102. package/dist/usage/http-routes.js.map +1 -0
  103. package/dist/usage/index.d.ts +11 -0
  104. package/dist/usage/index.d.ts.map +1 -0
  105. package/dist/usage/index.js +7 -0
  106. package/dist/usage/index.js.map +1 -0
  107. package/dist/usage/ui-brand.d.ts +23 -0
  108. package/dist/usage/ui-brand.d.ts.map +1 -0
  109. package/dist/usage/ui-brand.js +18 -0
  110. package/dist/usage/ui-brand.js.map +1 -0
  111. package/dist/usage/usage-analytics-productivity.d.ts +73 -0
  112. package/dist/usage/usage-analytics-productivity.d.ts.map +1 -0
  113. package/dist/usage/usage-analytics-productivity.js +317 -0
  114. package/dist/usage/usage-analytics-productivity.js.map +1 -0
  115. package/dist/usage/usage-analytics-shared.d.ts +5 -0
  116. package/dist/usage/usage-analytics-shared.d.ts.map +1 -0
  117. package/dist/usage/usage-analytics-shared.js +20 -0
  118. package/dist/usage/usage-analytics-shared.js.map +1 -0
  119. package/dist/usage/usage-analytics.d.ts +127 -0
  120. package/dist/usage/usage-analytics.d.ts.map +1 -0
  121. package/dist/usage/usage-analytics.js +338 -0
  122. package/dist/usage/usage-analytics.js.map +1 -0
  123. package/dist-cjs/api-endpoints/http-routes.js +14 -0
  124. package/dist-cjs/api-endpoints/http-routes.js.map +1 -0
  125. package/dist-cjs/api-endpoints/index.js +10 -0
  126. package/dist-cjs/api-endpoints/index.js.map +1 -0
  127. package/dist-cjs/api-endpoints/registry.js +107 -0
  128. package/dist-cjs/api-endpoints/registry.js.map +1 -0
  129. package/dist-cjs/api-mount.js +117 -0
  130. package/dist-cjs/api-mount.js.map +1 -0
  131. package/dist-cjs/helpers.js +41 -0
  132. package/dist-cjs/helpers.js.map +1 -0
  133. package/dist-cjs/index.js +118 -0
  134. package/dist-cjs/index.js.map +1 -0
  135. package/dist-cjs/memory-store.js +119 -0
  136. package/dist-cjs/memory-store.js.map +1 -0
  137. package/dist-cjs/ops/emit.js +18 -0
  138. package/dist-cjs/ops/emit.js.map +1 -0
  139. package/dist-cjs/ops/fleet-activity.js +131 -0
  140. package/dist-cjs/ops/fleet-activity.js.map +1 -0
  141. package/dist-cjs/ops/fleet-agent.js +358 -0
  142. package/dist-cjs/ops/fleet-agent.js.map +1 -0
  143. package/dist-cjs/ops/fleet-samples.js +229 -0
  144. package/dist-cjs/ops/fleet-samples.js.map +1 -0
  145. package/dist-cjs/ops/journal.js +326 -0
  146. package/dist-cjs/ops/journal.js.map +1 -0
  147. package/dist-cjs/ops/rules.js +102 -0
  148. package/dist-cjs/ops/rules.js.map +1 -0
  149. package/dist-cjs/ops/types.js +126 -0
  150. package/dist-cjs/ops/types.js.map +1 -0
  151. package/dist-cjs/package.json +3 -0
  152. package/dist-cjs/request-logs/config.js +42 -0
  153. package/dist-cjs/request-logs/config.js.map +1 -0
  154. package/dist-cjs/request-logs/http-routes.js +32 -0
  155. package/dist-cjs/request-logs/http-routes.js.map +1 -0
  156. package/dist-cjs/request-logs/index.js +27 -0
  157. package/dist-cjs/request-logs/index.js.map +1 -0
  158. package/dist-cjs/request-logs/middleware.js +203 -0
  159. package/dist-cjs/request-logs/middleware.js.map +1 -0
  160. package/dist-cjs/request-logs/request-logs.js +277 -0
  161. package/dist-cjs/request-logs/request-logs.js.map +1 -0
  162. package/dist-cjs/schema.js +24 -0
  163. package/dist-cjs/schema.js.map +1 -0
  164. package/dist-cjs/sqlite-driver.js +18 -0
  165. package/dist-cjs/sqlite-driver.js.map +1 -0
  166. package/dist-cjs/sqlite-store.js +182 -0
  167. package/dist-cjs/sqlite-store.js.map +1 -0
  168. package/dist-cjs/types.js +12 -0
  169. package/dist-cjs/types.js.map +1 -0
  170. package/dist-cjs/usage/adapters.js +47 -0
  171. package/dist-cjs/usage/adapters.js.map +1 -0
  172. package/dist-cjs/usage/http-routes.js +154 -0
  173. package/dist-cjs/usage/http-routes.js.map +1 -0
  174. package/dist-cjs/usage/index.js +34 -0
  175. package/dist-cjs/usage/index.js.map +1 -0
  176. package/dist-cjs/usage/ui-brand.js +23 -0
  177. package/dist-cjs/usage/ui-brand.js.map +1 -0
  178. package/dist-cjs/usage/usage-analytics-productivity.js +321 -0
  179. package/dist-cjs/usage/usage-analytics-productivity.js.map +1 -0
  180. package/dist-cjs/usage/usage-analytics-shared.js +23 -0
  181. package/dist-cjs/usage/usage-analytics-shared.js.map +1 -0
  182. package/dist-cjs/usage/usage-analytics.js +353 -0
  183. package/dist-cjs/usage/usage-analytics.js.map +1 -0
  184. package/fleet-collector/AGENTS.md +7 -0
  185. package/fleet-collector/README.md +83 -0
  186. package/fleet-collector/configure-fleet-npm.sh +108 -0
  187. package/fleet-collector/env.mjs +174 -0
  188. package/fleet-collector/ops-api.mjs +455 -0
  189. package/fleet-collector/public/admin.html +696 -0
  190. package/fleet-collector/public/index.html +941 -0
  191. package/fleet-collector/server.mjs +564 -0
  192. package/fleet-collector/test-fleet-collector.mjs +413 -0
  193. package/fleet-collector/test-server-admin.mjs +475 -0
  194. package/package.json +109 -0
  195. package/scripts/test-api-endpoints-registry.mjs +60 -0
  196. package/ui/analytics-client.tsx +834 -0
  197. package/ui/analytics-productivity-panel.tsx +513 -0
  198. package/ui/api-endpoints-client.tsx +228 -0
  199. package/ui/index.ts +39 -0
  200. package/ui/primitives/badge.tsx +37 -0
  201. package/ui/primitives/button.tsx +44 -0
  202. package/ui/primitives/card.tsx +44 -0
  203. package/ui/primitives/cn.ts +6 -0
  204. package/ui/primitives/input.tsx +19 -0
  205. package/ui/primitives/tabs.tsx +51 -0
  206. package/ui/request-logs-client.tsx +327 -0
  207. package/ui/session-usage-analytics-provider.tsx +28 -0
  208. package/ui/usage-analytics-client.ts +479 -0
  209. package/ui/usage-analytics-provider.tsx +84 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Tracker d'usage client — pages, dwell, clics, présence (heartbeat / idle / focus).
3
+ * Buffer + flush vers POST /api/v1/analytics/events.
4
+ * Miroir optionnel vers la télémétrie flotte Electron.
5
+ *
6
+ * Vie privée : on ne journalise PAS le contenu des frappes ni les mouvements souris,
7
+ * seulement des signaux d'activité agrégés (heartbeat / idle).
8
+ */
9
+
10
+ import { getUsageAnalyticsUiBrand, type FleetActionPayload } from "../dist/usage/ui-brand.js";
11
+
12
+ function mirrorFleetAction(payload: FleetActionPayload): void {
13
+ getUsageAnalyticsUiBrand().mirrorFleetAction?.(payload);
14
+ }
15
+
16
+ function aidAttr(): string {
17
+ return getUsageAnalyticsUiBrand().aidAttr;
18
+ }
19
+
20
+ function titlebarNoDragClass(): string {
21
+ return getUsageAnalyticsUiBrand().titlebarNoDragClass;
22
+ }
23
+
24
+ type Session = {
25
+ userId: string | null;
26
+ username: string | null;
27
+ userKind: "human" | "ai" | "unknown";
28
+ userRole: string | null;
29
+ sessionId: string | null;
30
+ };
31
+
32
+ type QueuedEvent = {
33
+ eventType: string;
34
+ category: string;
35
+ label: string;
36
+ path?: string;
37
+ referrerPath?: string;
38
+ sessionId?: string;
39
+ durationMs?: number;
40
+ meta?: Record<string, unknown>;
41
+ surface?: string;
42
+ createdAt: string;
43
+ };
44
+
45
+ let session: Session = {
46
+ userId: null,
47
+ username: null,
48
+ userKind: "unknown",
49
+ userRole: null,
50
+ sessionId: null,
51
+ };
52
+
53
+ let currentPath: string | null = null;
54
+ let pageEnteredAt = 0;
55
+ let clickBound = false;
56
+ let visibilityBound = false;
57
+ let presenceBound = false;
58
+ let flushTimer: number | null = null;
59
+ let queue: QueuedEvent[] = [];
60
+ let flushing = false;
61
+ const MAX_QUEUE = 200;
62
+ const FLUSH_MS = 2500;
63
+
64
+ /** Heartbeat toutes les 60s si onglet visible et non idle. */
65
+ const HEARTBEAT_MS = 60_000;
66
+ /** Idle après 2 min sans interaction. */
67
+ const IDLE_AFTER_MS = 2 * 60_000;
68
+
69
+ let lastActivityAt = 0;
70
+ let idleSince: number | null = null;
71
+ let heartbeatTimer: number | null = null;
72
+ let idleCheckTimer: number | null = null;
73
+ let sessionStartedAt = 0;
74
+ let focused = true;
75
+
76
+ function mirrorFleet(ev: QueuedEvent): void {
77
+ // Gaté : sur un client distant, fleet:action est host-only (voir desktop-host).
78
+ mirrorFleetAction({
79
+ type: ev.eventType,
80
+ label: ev.label,
81
+ path: ev.path,
82
+ userId: session.userId ?? undefined,
83
+ username: session.username ?? undefined,
84
+ meta: {
85
+ ...(ev.meta || {}),
86
+ name: ev.eventType,
87
+ category: ev.category,
88
+ sessionId: ev.sessionId ?? session.sessionId,
89
+ surface: ev.surface || "crm",
90
+ referrerPath: ev.referrerPath,
91
+ durationMs: ev.durationMs,
92
+ userKind: session.userKind,
93
+ userRole: session.userRole,
94
+ },
95
+ });
96
+ }
97
+
98
+ function enqueue(partial: Omit<QueuedEvent, "createdAt"> & { createdAt?: string }): void {
99
+ if (!session.userId && !session.sessionId) {
100
+ if (
101
+ !partial.eventType.startsWith("page.") &&
102
+ partial.eventType !== "ui.click" &&
103
+ !partial.eventType.startsWith("presence.")
104
+ ) {
105
+ return;
106
+ }
107
+ }
108
+ const ev: QueuedEvent = {
109
+ ...partial,
110
+ sessionId: partial.sessionId ?? session.sessionId ?? undefined,
111
+ createdAt: partial.createdAt || new Date().toISOString(),
112
+ };
113
+ queue.push(ev);
114
+ if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
115
+ mirrorFleet(ev);
116
+ scheduleFlush();
117
+ }
118
+
119
+ function scheduleFlush(): void {
120
+ if (typeof window === "undefined") return;
121
+ if (flushTimer != null) return;
122
+ flushTimer = window.setTimeout(() => {
123
+ flushTimer = null;
124
+ void flushUsageAnalytics();
125
+ }, FLUSH_MS);
126
+ }
127
+
128
+ export async function flushUsageAnalytics(): Promise<void> {
129
+ if (flushing || !queue.length) return;
130
+ if (typeof window === "undefined" || typeof fetch !== "function") return;
131
+ flushing = true;
132
+ const batch = queue.splice(0, 100);
133
+ try {
134
+ const res = await fetch("/api/v1/analytics/events", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ events: batch }),
138
+ keepalive: true,
139
+ });
140
+ if (!res.ok) {
141
+ queue = [...batch, ...queue].slice(0, MAX_QUEUE);
142
+ }
143
+ } catch {
144
+ queue = [...batch, ...queue].slice(0, MAX_QUEUE);
145
+ } finally {
146
+ flushing = false;
147
+ if (queue.length) scheduleFlush();
148
+ }
149
+ }
150
+
151
+ export function setUsageAnalyticsSession(next: {
152
+ userId?: string | null;
153
+ username?: string | null;
154
+ userKind?: "human" | "ai" | "unknown" | null;
155
+ userRole?: string | null;
156
+ sessionId?: string | null;
157
+ }): void {
158
+ const prev = session.userId;
159
+ session = {
160
+ userId: next.userId !== undefined ? next.userId : session.userId,
161
+ username: next.username !== undefined ? next.username : session.username,
162
+ userKind: next.userKind !== undefined && next.userKind ? next.userKind : session.userKind,
163
+ userRole: next.userRole !== undefined ? next.userRole : session.userRole,
164
+ sessionId: next.sessionId !== undefined ? next.sessionId : session.sessionId,
165
+ };
166
+ if (session.userId && session.userId !== prev) {
167
+ sessionStartedAt = Date.now();
168
+ lastActivityAt = Date.now();
169
+ idleSince = null;
170
+ enqueue({
171
+ eventType: "session.start",
172
+ category: "auth",
173
+ label: `Session ${session.username || session.userId}`,
174
+ path: typeof location !== "undefined" ? location.pathname : undefined,
175
+ meta: { kind: session.userKind, role: session.userRole },
176
+ });
177
+ startPresenceLoops();
178
+ }
179
+ if (!session.userId) {
180
+ stopPresenceLoops();
181
+ }
182
+ }
183
+
184
+ function closePage(reason: string): void {
185
+ if (!currentPath || !pageEnteredAt) return;
186
+ const durationMs = Date.now() - pageEnteredAt;
187
+ enqueue({
188
+ eventType: "page.hide",
189
+ category: "navigation",
190
+ label: `Quitte ${currentPath}`,
191
+ path: currentPath,
192
+ durationMs,
193
+ meta: { reason },
194
+ });
195
+ pageEnteredAt = 0;
196
+ }
197
+
198
+ export function trackUsagePageView(path: string): void {
199
+ const next = String(path || "").slice(0, 300);
200
+ if (!next) return;
201
+ if (currentPath === next && pageEnteredAt) return;
202
+ const prev = currentPath;
203
+ if (prev) closePage("navigate");
204
+ currentPath = next;
205
+ pageEnteredAt = Date.now();
206
+ markActivity("navigate");
207
+ enqueue({
208
+ eventType: "page.view",
209
+ category: "navigation",
210
+ label: `Page ${next}`,
211
+ path: next,
212
+ referrerPath: prev || undefined,
213
+ });
214
+ }
215
+
216
+ export function trackUsageEvent(opts: {
217
+ eventType: string;
218
+ category?: string;
219
+ label: string;
220
+ path?: string;
221
+ durationMs?: number;
222
+ meta?: Record<string, unknown>;
223
+ surface?: string;
224
+ }): void {
225
+ enqueue({
226
+ eventType: opts.eventType,
227
+ category: opts.category || "ui",
228
+ label: opts.label,
229
+ path: opts.path || currentPath || undefined,
230
+ durationMs: opts.durationMs,
231
+ meta: opts.meta,
232
+ surface: opts.surface,
233
+ });
234
+ }
235
+
236
+ function clickLabel(el: Element): string {
237
+ const aid = el.getAttribute(aidAttr());
238
+ if (aid) return aid;
239
+ const aria = el.getAttribute("aria-label");
240
+ if (aria) return aria.slice(0, 120);
241
+ const title = el.getAttribute("title");
242
+ if (title) return title.slice(0, 120);
243
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
244
+ if (text) return text.slice(0, 120);
245
+ const tag = el.tagName.toLowerCase();
246
+ const href = el.getAttribute("href");
247
+ if (href) return `${tag} ${href}`.slice(0, 120);
248
+ return tag;
249
+ }
250
+
251
+ function onClickCapture(ev: MouseEvent): void {
252
+ const t = ev.target;
253
+ if (!(t instanceof Element)) return;
254
+ markActivity("click");
255
+ const el =
256
+ t.closest(
257
+ `[${aidAttr()}],button,a,[role='button'],[role='tab'],input[type='submit']`,
258
+ ) || null;
259
+ if (!el) return;
260
+ if (el.closest(`.${titlebarNoDragClass()}`)) return;
261
+ if (
262
+ el instanceof HTMLInputElement &&
263
+ el.type !== "submit" &&
264
+ el.type !== "button" &&
265
+ el.type !== "checkbox" &&
266
+ el.type !== "radio"
267
+ ) {
268
+ return;
269
+ }
270
+ const aid = el.getAttribute(aidAttr()) || undefined;
271
+ enqueue({
272
+ eventType: "ui.click",
273
+ category: "ui",
274
+ label: clickLabel(el),
275
+ path: currentPath || (typeof location !== "undefined" ? location.pathname : undefined),
276
+ meta: {
277
+ aid: aid || null,
278
+ tag: el.tagName.toLowerCase(),
279
+ role: el.getAttribute("role") || null,
280
+ },
281
+ });
282
+ }
283
+
284
+ function markActivity(reason: string, opts?: { force?: boolean }): void {
285
+ const now = Date.now();
286
+ // Throttle des signaux souris/clavier (sauf sortie d'idle / force)
287
+ if (!opts?.force && idleSince == null && now - lastActivityAt < 1_000) {
288
+ lastActivityAt = now;
289
+ return;
290
+ }
291
+ if (idleSince != null) {
292
+ const idleMs = now - idleSince;
293
+ enqueue({
294
+ eventType: "presence.active",
295
+ category: "presence",
296
+ label: "Retour activité",
297
+ path: currentPath || undefined,
298
+ durationMs: idleMs,
299
+ meta: { reason, idleMs },
300
+ });
301
+ // l'idle écoulé est aussi journalisé pour les agrégations
302
+ enqueue({
303
+ eventType: "presence.idle",
304
+ category: "presence",
305
+ label: "Pause / idle",
306
+ path: currentPath || undefined,
307
+ durationMs: idleMs,
308
+ meta: { reason: "ended", endedBy: reason },
309
+ createdAt: new Date(idleSince).toISOString(),
310
+ });
311
+ idleSince = null;
312
+ }
313
+ lastActivityAt = now;
314
+ }
315
+
316
+ function tickIdleCheck(): void {
317
+ if (!session.userId) return;
318
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
319
+ if (!focused) return;
320
+ const now = Date.now();
321
+ if (idleSince == null && lastActivityAt && now - lastActivityAt >= IDLE_AFTER_MS) {
322
+ idleSince = lastActivityAt;
323
+ enqueue({
324
+ eventType: "presence.idle_start",
325
+ category: "presence",
326
+ label: "Début idle",
327
+ path: currentPath || undefined,
328
+ meta: { afterMs: IDLE_AFTER_MS },
329
+ });
330
+ }
331
+ }
332
+
333
+ function tickHeartbeat(): void {
334
+ if (!session.userId) return;
335
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
336
+ if (!focused) return;
337
+ if (idleSince != null) return;
338
+ enqueue({
339
+ eventType: "presence.heartbeat",
340
+ category: "presence",
341
+ label: "Présence active",
342
+ path: currentPath || undefined,
343
+ durationMs: HEARTBEAT_MS,
344
+ meta: { intervalMs: HEARTBEAT_MS },
345
+ });
346
+ }
347
+
348
+ function startPresenceLoops(): void {
349
+ if (typeof window === "undefined") return;
350
+ stopPresenceLoops();
351
+ lastActivityAt = Date.now();
352
+ heartbeatTimer = window.setInterval(() => tickHeartbeat(), HEARTBEAT_MS);
353
+ idleCheckTimer = window.setInterval(() => tickIdleCheck(), 15_000);
354
+ // premier heartbeat rapide pour amorcer la journée
355
+ window.setTimeout(() => tickHeartbeat(), 5_000);
356
+ }
357
+
358
+ function stopPresenceLoops(): void {
359
+ if (typeof window === "undefined") return;
360
+ if (heartbeatTimer != null) {
361
+ window.clearInterval(heartbeatTimer);
362
+ heartbeatTimer = null;
363
+ }
364
+ if (idleCheckTimer != null) {
365
+ window.clearInterval(idleCheckTimer);
366
+ idleCheckTimer = null;
367
+ }
368
+ }
369
+
370
+ function onVisibility(): void {
371
+ if (document.visibilityState === "hidden") {
372
+ closePage("hidden");
373
+ if (idleSince == null && lastActivityAt) {
374
+ // onglet caché ≈ fin de focus
375
+ enqueue({
376
+ eventType: "presence.blur",
377
+ category: "presence",
378
+ label: "Onglet masqué",
379
+ path: currentPath || undefined,
380
+ meta: { reason: "hidden" },
381
+ });
382
+ }
383
+ void flushUsageAnalytics();
384
+ } else if (document.visibilityState === "visible" && currentPath) {
385
+ pageEnteredAt = Date.now();
386
+ markActivity("visible");
387
+ enqueue({
388
+ eventType: "page.view",
389
+ category: "navigation",
390
+ label: `Retour ${currentPath}`,
391
+ path: currentPath,
392
+ meta: { reason: "visible" },
393
+ });
394
+ enqueue({
395
+ eventType: "presence.focus",
396
+ category: "presence",
397
+ label: "Onglet visible",
398
+ path: currentPath,
399
+ meta: { reason: "visible" },
400
+ });
401
+ }
402
+ }
403
+
404
+ function onWindowBlur(): void {
405
+ focused = false;
406
+ enqueue({
407
+ eventType: "presence.blur",
408
+ category: "presence",
409
+ label: "Fenêtre blur",
410
+ path: currentPath || undefined,
411
+ });
412
+ }
413
+
414
+ function onWindowFocus(): void {
415
+ focused = true;
416
+ markActivity("focus");
417
+ enqueue({
418
+ eventType: "presence.focus",
419
+ category: "presence",
420
+ label: "Fenêtre focus",
421
+ path: currentPath || undefined,
422
+ });
423
+ }
424
+
425
+ function onPageHide(): void {
426
+ closePage("pagehide");
427
+ if (session.userId && sessionStartedAt) {
428
+ enqueue({
429
+ eventType: "session.end",
430
+ category: "auth",
431
+ label: "Fin de session",
432
+ path: currentPath || undefined,
433
+ durationMs: Date.now() - sessionStartedAt,
434
+ });
435
+ }
436
+ if (idleSince != null) {
437
+ enqueue({
438
+ eventType: "presence.idle",
439
+ category: "presence",
440
+ label: "Pause / idle",
441
+ path: currentPath || undefined,
442
+ durationMs: Date.now() - idleSince,
443
+ meta: { reason: "pagehide" },
444
+ createdAt: new Date(idleSince).toISOString(),
445
+ });
446
+ idleSince = null;
447
+ }
448
+ void flushUsageAnalytics();
449
+ }
450
+
451
+ function onActivitySignal(): void {
452
+ markActivity("signal");
453
+ }
454
+
455
+ export function ensureUsageAnalyticsDom(): void {
456
+ if (typeof document === "undefined") return;
457
+ if (!clickBound) {
458
+ document.addEventListener("click", onClickCapture, true);
459
+ clickBound = true;
460
+ }
461
+ if (!visibilityBound) {
462
+ document.addEventListener("visibilitychange", onVisibility);
463
+ window.addEventListener("pagehide", onPageHide);
464
+ visibilityBound = true;
465
+ }
466
+ if (!presenceBound) {
467
+ window.addEventListener("blur", onWindowBlur);
468
+ window.addEventListener("focus", onWindowFocus);
469
+ // Signaux d'activité — pas journalisés unitairement
470
+ const opts = { passive: true, capture: true } as const;
471
+ document.addEventListener("mousemove", onActivitySignal, opts);
472
+ document.addEventListener("keydown", onActivitySignal, opts);
473
+ document.addEventListener("scroll", onActivitySignal, opts);
474
+ document.addEventListener("touchstart", onActivitySignal, opts);
475
+ document.addEventListener("pointerdown", onActivitySignal, opts);
476
+ presenceBound = true;
477
+ if (session.userId) startPresenceLoops();
478
+ }
479
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Monte le tracker d'usage local : session, page views (dwell), capture clics.
5
+ * Persiste via /api/v1/analytics/events (SQLite) + miroir flotte Electron.
6
+ *
7
+ * Port TempoFlow — N6. Session injectée (pas de `@/` marque).
8
+ */
9
+
10
+ import { useEffect, useRef, type ReactNode } from "react";
11
+ import { usePathname, useSearchParams } from "next/navigation";
12
+ import {
13
+ ensureUsageAnalyticsDom,
14
+ flushUsageAnalytics,
15
+ setUsageAnalyticsSession,
16
+ trackUsagePageView,
17
+ } from "./usage-analytics-client";
18
+
19
+ export type UsageAnalyticsProviderSession = {
20
+ user_id?: string | null;
21
+ user?: string | null;
22
+ kind?: string | null;
23
+ role?: string | null;
24
+ } | null;
25
+
26
+ function newSessionId(): string {
27
+ try {
28
+ return crypto.randomUUID();
29
+ } catch {
30
+ return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
31
+ }
32
+ }
33
+
34
+ export function UsageAnalyticsProvider({
35
+ children,
36
+ me,
37
+ }: {
38
+ children: ReactNode;
39
+ /** Session courante (marque) — null si anonyme. */
40
+ me: UsageAnalyticsProviderSession;
41
+ }) {
42
+ const pathname = usePathname();
43
+ const search = useSearchParams();
44
+ const sessionIdRef = useRef<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ ensureUsageAnalyticsDom();
48
+ return () => {
49
+ void flushUsageAnalytics();
50
+ };
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (!me) {
55
+ setUsageAnalyticsSession({
56
+ userId: null,
57
+ username: null,
58
+ userKind: "unknown",
59
+ userRole: null,
60
+ sessionId: null,
61
+ });
62
+ sessionIdRef.current = null;
63
+ return;
64
+ }
65
+ if (!sessionIdRef.current) sessionIdRef.current = newSessionId();
66
+ const userId = me.user_id || me.user;
67
+ setUsageAnalyticsSession({
68
+ userId: userId ?? null,
69
+ username: me.user ?? null,
70
+ userKind: me.kind === "ai" ? "ai" : "human",
71
+ userRole: me.role ?? null,
72
+ sessionId: sessionIdRef.current,
73
+ });
74
+ }, [me]);
75
+
76
+ useEffect(() => {
77
+ if (!pathname) return;
78
+ const q = search?.toString();
79
+ const path = q ? `${pathname}?${q}` : pathname;
80
+ trackUsagePageView(path);
81
+ }, [pathname, search]);
82
+
83
+ return <>{children}</>;
84
+ }