@holdyapp/sdk 1.1.0 → 1.6.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/README.md +53 -1
- package/dist/arm.d.ts +5 -0
- package/dist/arm.d.ts.map +1 -0
- package/dist/changes.d.ts +42 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/core.d.ts +17 -6
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +91 -0
- package/dist/friction.d.ts.map +1 -0
- package/dist/holdy.js +6 -1
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +871 -26
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +871 -26
- package/dist/index.mjs.map +4 -4
- package/dist/run.d.ts +37 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/server.cjs +127 -23
- package/dist/server.cjs.map +3 -3
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.mjs +124 -22
- package/dist/server.mjs.map +3 -3
- package/dist/skeleton.d.ts +50 -0
- package/dist/skeleton.d.ts.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web.d.ts +26 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +824 -26
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/arm.test.ts +47 -0
- package/src/arm.ts +25 -0
- package/src/changes.test.ts +54 -0
- package/src/changes.ts +357 -0
- package/src/core.test.ts +78 -4
- package/src/core.ts +95 -31
- package/src/friction.test.ts +223 -0
- package/src/friction.ts +419 -0
- package/src/index.ts +1 -0
- package/src/run.test.ts +47 -0
- package/src/run.ts +80 -0
- package/src/server.ts +17 -0
- package/src/skeleton.test.ts +60 -0
- package/src/skeleton.ts +173 -0
- package/src/types.ts +2 -0
- package/src/web.ts +71 -1
package/src/run.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execução como unidade de trabalho (source: sdk_server).
|
|
3
|
+
*
|
|
4
|
+
* Produto de agente não tem sessão de browser: o trabalho roda no servidor,
|
|
5
|
+
* por API, em background. Pra Holdy dizer "começou e concluiu na mesma
|
|
6
|
+
* execução" e dividir recurso por resultado entregue, todos os eventos de uma
|
|
7
|
+
* tarefa carregam a mesma `ai_run_id` — a chave que o composer usa por padrão
|
|
8
|
+
* no grão de execução (lib/knowledge/compose-definition.ts).
|
|
9
|
+
*
|
|
10
|
+
* Não inventa vocabulário: início e fim saem com o nome que o cliente deu à
|
|
11
|
+
* execução (`support_agent.started`), e todo passo no meio é nome dele. O
|
|
12
|
+
* retorno passa direto e o erro propaga; só o NOME do erro vira propriedade,
|
|
13
|
+
* porque a mensagem carrega dado do cliente com frequência demais.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Properties, TrackOverrides } from './types'
|
|
17
|
+
|
|
18
|
+
export interface RunOptions {
|
|
19
|
+
/** Nome da execução no vocabulário do produto, ex.: 'support_agent'. */
|
|
20
|
+
name: string
|
|
21
|
+
/** Conta (id externo) dona da execução. */
|
|
22
|
+
accountKey?: string
|
|
23
|
+
userId?: string
|
|
24
|
+
/** Chave própria (trace id do agente); ausente = gerada aqui. */
|
|
25
|
+
runId?: string
|
|
26
|
+
/** Propriedades repetidas em todos os eventos da execução. */
|
|
27
|
+
properties?: Properties
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RunHandle {
|
|
31
|
+
/** A chave que agrupa tudo desta execução. */
|
|
32
|
+
id: string
|
|
33
|
+
/** Um passo dentro da execução, já carimbado com ai_run_id. */
|
|
34
|
+
track(event: string, properties?: Properties): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TrackFn = (name: string, properties?: Properties, overrides?: TrackOverrides) => void
|
|
38
|
+
|
|
39
|
+
export function newRunId(): string {
|
|
40
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
|
|
41
|
+
if (typeof c?.randomUUID === 'function') return c.randomUUID()
|
|
42
|
+
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function runEventName(name: string, suffix: 'started' | 'completed' | 'failed'): string {
|
|
46
|
+
const base = name.trim().replace(/\.$/, '')
|
|
47
|
+
return `${base || 'run'}.${suffix}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function withRun<T>(
|
|
51
|
+
track: TrackFn,
|
|
52
|
+
opts: RunOptions,
|
|
53
|
+
fn: (run: RunHandle) => Promise<T> | T,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
if (!opts || !opts.name) throw new Error('[holdy] run: name is required')
|
|
56
|
+
|
|
57
|
+
const id = opts.runId && opts.runId.length > 0 ? opts.runId : newRunId()
|
|
58
|
+
const base: Properties = { ...(opts.properties ?? {}), ai_run_id: id }
|
|
59
|
+
const overrides: TrackOverrides = {
|
|
60
|
+
...(opts.accountKey ? { accountKey: opts.accountKey } : {}),
|
|
61
|
+
...(opts.userId ? { userId: opts.userId } : {}),
|
|
62
|
+
}
|
|
63
|
+
const emit = (event: string, properties?: Properties) =>
|
|
64
|
+
track(event, { ...base, ...(properties ?? {}), ai_run_id: id }, overrides)
|
|
65
|
+
|
|
66
|
+
const startedAt = Date.now()
|
|
67
|
+
emit(runEventName(opts.name, 'started'))
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await fn({ id, track: emit })
|
|
71
|
+
emit(runEventName(opts.name, 'completed'), { duration_ms: Date.now() - startedAt })
|
|
72
|
+
return result
|
|
73
|
+
} catch (err) {
|
|
74
|
+
emit(runEventName(opts.name, 'failed'), {
|
|
75
|
+
duration_ms: Date.now() - startedAt,
|
|
76
|
+
error: err instanceof Error ? err.name : 'Error',
|
|
77
|
+
})
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -7,13 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { HoldyCore } from './core'
|
|
10
|
+
import { withRun, type RunHandle, type RunOptions } from './run'
|
|
10
11
|
import type { HoldyOptions } from './types'
|
|
11
12
|
|
|
12
13
|
export class Holdy extends HoldyCore {
|
|
13
14
|
constructor(opts: HoldyOptions) {
|
|
14
15
|
super('sdk_server', opts)
|
|
15
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executa `fn` como UMA execução de agente: todo evento lá dentro carrega a
|
|
20
|
+
* mesma `ai_run_id`, e `<name>.started` / `.completed` / `.failed` saem
|
|
21
|
+
* sozinhos.
|
|
22
|
+
*
|
|
23
|
+
* await holdy.run({ name: 'support_agent', accountKey: 'acme' }, async (run) => {
|
|
24
|
+
* run.track('ticket_drafted', { ai_tokens: 1200, ai_cost_usd: 0.02 })
|
|
25
|
+
* return resolveTicket(ticket)
|
|
26
|
+
* })
|
|
27
|
+
*/
|
|
28
|
+
run<T>(opts: RunOptions, fn: (run: RunHandle) => Promise<T> | T): Promise<T> {
|
|
29
|
+
return withRun((name, properties, overrides) => this.track(name, properties, overrides), opts, fn)
|
|
30
|
+
}
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export { HoldyCore, DEFAULT_BASE_URL } from './core'
|
|
19
34
|
export type { HoldyOptions, Properties, TrackOverrides, CanonicalEvent } from './types'
|
|
35
|
+
export { newRunId, runEventName } from './run'
|
|
36
|
+
export type { RunHandle, RunOptions } from './run'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { collectSkeleton, describeSkeletonElement, fnv1a, SKELETON_MAX_ELEMENTS, type SkeletonElementLike } from './skeleton'
|
|
3
|
+
|
|
4
|
+
function el(tag: string, attrs: Record<string, string> = {}, rect = { x: 10, y: 20, width: 100, height: 40 }): SkeletonElementLike {
|
|
5
|
+
return {
|
|
6
|
+
tagName: tag.toUpperCase(),
|
|
7
|
+
getAttribute: (name) => attrs[name] ?? null,
|
|
8
|
+
getBoundingClientRect: () => rect,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const viewport = { width: 1000, height: 2000, scrollY: 0 }
|
|
13
|
+
|
|
14
|
+
describe('fnv1a', () => {
|
|
15
|
+
it('é estável e conhecido', () => {
|
|
16
|
+
expect(fnv1a('')).toBe('811c9dc5')
|
|
17
|
+
expect(fnv1a('a')).toBe('e40c292c')
|
|
18
|
+
expect(fnv1a('holdy')).toBe(fnv1a('holdy'))
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('describeSkeletonElement', () => {
|
|
23
|
+
it('guarda tag, role, data-testid, name, aria-label e tipo, nunca texto nem valor', () => {
|
|
24
|
+
const out = describeSkeletonElement(
|
|
25
|
+
el('input', { type: 'Email', name: 'email', 'aria-label': ' Seu e-mail ', value: 'segredo@x.com', placeholder: 'Digite' }),
|
|
26
|
+
viewport,
|
|
27
|
+
)
|
|
28
|
+
expect(out).toEqual({ t: 'input', n: 'email', l: 'Seu e-mail', k: 'email', b: [1, 1, 10, 2] })
|
|
29
|
+
expect(JSON.stringify(out)).not.toContain('segredo')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('sem viewport não há geometria', () => {
|
|
33
|
+
expect(describeSkeletonElement(el('button', { 'data-testid': 'continue' }), null)).toEqual({ t: 'button', id: 'continue' })
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('collectSkeleton', () => {
|
|
38
|
+
const root = (items: SkeletonElementLike[]) => ({ querySelectorAll: () => items })
|
|
39
|
+
|
|
40
|
+
it('o hash ignora a geometria e muda com a estrutura', () => {
|
|
41
|
+
const a = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 0, y: 0, width: 50, height: 20 })]), '/x', viewport)
|
|
42
|
+
const b = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 300, y: 900, width: 80, height: 30 })]), '/x', viewport)
|
|
43
|
+
const c = collectSkeleton(root([el('button', { 'data-testid': 'stop' })]), '/x', viewport)
|
|
44
|
+
expect(a.hash).toBe(b.hash)
|
|
45
|
+
expect(a.hash).not.toBe(c.hash)
|
|
46
|
+
expect(a.elements[0].b).toEqual([0, 0, 5, 1])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('deixa fora input hidden e elemento sem tamanho, e conta o total antes do teto', () => {
|
|
50
|
+
const many = Array.from({ length: SKELETON_MAX_ELEMENTS + 10 }, (_, i) => el('a', { href: `/p/${i}` }))
|
|
51
|
+
const out = collectSkeleton(
|
|
52
|
+
root([el('input', { type: 'hidden', name: 'csrf' }), el('button', {}, { x: 0, y: 0, width: 0, height: 0 }), ...many]),
|
|
53
|
+
'/lista',
|
|
54
|
+
viewport,
|
|
55
|
+
)
|
|
56
|
+
expect(out.count).toBe(SKELETON_MAX_ELEMENTS + 10)
|
|
57
|
+
expect(out.elements).toHaveLength(SKELETON_MAX_ELEMENTS)
|
|
58
|
+
expect(out.elements.every((e) => e.t === 'a')).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/skeleton.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export const SKELETON_SELECTOR =
|
|
2
|
+
'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]'
|
|
3
|
+
export const SKELETON_MAX_ELEMENTS = 80
|
|
4
|
+
export const SKELETON_DELAY_MS = 1500
|
|
5
|
+
const SENT_STORAGE_KEY = 'holdy_skeletons'
|
|
6
|
+
const SENT_STORAGE_MAX = 200
|
|
7
|
+
|
|
8
|
+
export interface SkeletonRect {
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
width: number
|
|
12
|
+
height: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SkeletonElementLike {
|
|
16
|
+
tagName: string
|
|
17
|
+
getAttribute(name: string): string | null
|
|
18
|
+
getBoundingClientRect?(): SkeletonRect
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SkeletonRootLike {
|
|
22
|
+
querySelectorAll(selector: string): ArrayLike<SkeletonElementLike>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SkeletonViewport {
|
|
26
|
+
width: number
|
|
27
|
+
height: number
|
|
28
|
+
scrollY: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SkeletonElement {
|
|
32
|
+
t: string
|
|
33
|
+
r?: string
|
|
34
|
+
id?: string
|
|
35
|
+
n?: string
|
|
36
|
+
l?: string
|
|
37
|
+
k?: string
|
|
38
|
+
b?: [number, number, number, number]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PageSkeleton {
|
|
42
|
+
path: string
|
|
43
|
+
hash: string
|
|
44
|
+
count: number
|
|
45
|
+
elements: SkeletonElement[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function fnv1a(input: string): string {
|
|
49
|
+
let hash = 0x811c9dc5
|
|
50
|
+
for (let i = 0; i < input.length; i++) {
|
|
51
|
+
hash ^= input.charCodeAt(i)
|
|
52
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
53
|
+
}
|
|
54
|
+
return hash.toString(16).padStart(8, '0')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attr(el: SkeletonElementLike, name: string, max: number): string | undefined {
|
|
58
|
+
const value = el.getAttribute(name)
|
|
59
|
+
if (!value) return undefined
|
|
60
|
+
const trimmed = value.replace(/\s+/g, ' ').trim().slice(0, max)
|
|
61
|
+
return trimmed || undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isVisible(el: SkeletonElementLike): boolean {
|
|
65
|
+
if (!el.getBoundingClientRect) return true
|
|
66
|
+
const rect = el.getBoundingClientRect()
|
|
67
|
+
return rect.width > 0 && rect.height > 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function relativeBox(rect: SkeletonRect, viewport: SkeletonViewport): [number, number, number, number] {
|
|
71
|
+
const pct = (value: number, base: number) => (base > 0 ? Math.max(0, Math.min(100, Math.round((value / base) * 100))) : 0)
|
|
72
|
+
return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function describeSkeletonElement(el: SkeletonElementLike, viewport: SkeletonViewport | null): SkeletonElement {
|
|
76
|
+
const out: SkeletonElement = { t: el.tagName.toLowerCase() }
|
|
77
|
+
const role = attr(el, 'role', 40)
|
|
78
|
+
if (role) out.r = role
|
|
79
|
+
const testid = attr(el, 'data-testid', 80)
|
|
80
|
+
if (testid) out.id = testid
|
|
81
|
+
const name = attr(el, 'name', 80)
|
|
82
|
+
if (name) out.n = name
|
|
83
|
+
const label = attr(el, 'aria-label', 80)
|
|
84
|
+
if (label) out.l = label
|
|
85
|
+
if (out.t === 'input' || out.t === 'button') {
|
|
86
|
+
const type = attr(el, 'type', 20)
|
|
87
|
+
if (type) out.k = type.toLowerCase()
|
|
88
|
+
}
|
|
89
|
+
if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport)
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function collectSkeleton(root: SkeletonRootLike, path: string, viewport: SkeletonViewport | null): PageSkeleton {
|
|
94
|
+
const found = root.querySelectorAll(SKELETON_SELECTOR)
|
|
95
|
+
const visible: SkeletonElementLike[] = []
|
|
96
|
+
for (let i = 0; i < found.length; i++) {
|
|
97
|
+
const el = found[i]
|
|
98
|
+
if (el.tagName.toLowerCase() === 'input' && (el.getAttribute('type') ?? '').toLowerCase() === 'hidden') continue
|
|
99
|
+
if (!isVisible(el)) continue
|
|
100
|
+
visible.push(el)
|
|
101
|
+
}
|
|
102
|
+
const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport))
|
|
103
|
+
const structural = elements.map(({ b: _b, ...rest }) => rest)
|
|
104
|
+
return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SkeletonCaptureOptions {
|
|
108
|
+
onSkeleton: (skeleton: PageSkeleton) => void
|
|
109
|
+
delayMs?: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SkeletonCapture {
|
|
113
|
+
onNavigate: (path: string) => void
|
|
114
|
+
teardown: () => void
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loadSent(): Set<string> {
|
|
118
|
+
try {
|
|
119
|
+
const raw = sessionStorage.getItem(SENT_STORAGE_KEY)
|
|
120
|
+
const list = raw ? (JSON.parse(raw) as unknown) : []
|
|
121
|
+
return new Set(Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [])
|
|
122
|
+
} catch {
|
|
123
|
+
return new Set()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function saveSent(sent: Set<string>): void {
|
|
128
|
+
try {
|
|
129
|
+
sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)))
|
|
130
|
+
} catch {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function installSkeletonCapture(opts: SkeletonCaptureOptions): SkeletonCapture {
|
|
136
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
137
|
+
return { onNavigate: () => undefined, teardown: () => undefined }
|
|
138
|
+
}
|
|
139
|
+
const delay = opts.delayMs ?? SKELETON_DELAY_MS
|
|
140
|
+
const sent = loadSent()
|
|
141
|
+
let pending: ReturnType<typeof setTimeout> | null = null
|
|
142
|
+
|
|
143
|
+
const capture = (path: string): void => {
|
|
144
|
+
pending = null
|
|
145
|
+
if (location.pathname !== path) return
|
|
146
|
+
try {
|
|
147
|
+
const viewport: SkeletonViewport = {
|
|
148
|
+
width: window.innerWidth,
|
|
149
|
+
height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
|
|
150
|
+
scrollY: window.scrollY,
|
|
151
|
+
}
|
|
152
|
+
const skeleton = collectSkeleton(document, path, viewport)
|
|
153
|
+
const key = `${path}:${skeleton.hash}`
|
|
154
|
+
if (sent.has(key)) return
|
|
155
|
+
sent.add(key)
|
|
156
|
+
saveSent(sent)
|
|
157
|
+
opts.onSkeleton(skeleton)
|
|
158
|
+
} catch {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
onNavigate: (path: string) => {
|
|
165
|
+
if (pending) clearTimeout(pending)
|
|
166
|
+
pending = setTimeout(() => capture(path), delay)
|
|
167
|
+
},
|
|
168
|
+
teardown: () => {
|
|
169
|
+
if (pending) clearTimeout(pending)
|
|
170
|
+
pending = null
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface CanonicalEvent {
|
|
|
12
12
|
event: string
|
|
13
13
|
occurred_at: string
|
|
14
14
|
source: 'sdk_web' | 'sdk_server'
|
|
15
|
+
/** Id do evento gerado no SDK · torna o reenvio de um lote idempotente. */
|
|
16
|
+
external_id?: string
|
|
15
17
|
user_id?: string
|
|
16
18
|
anonymous_id?: string
|
|
17
19
|
properties: Properties
|
package/src/web.ts
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { HoldyCore } from './core'
|
|
15
15
|
import { installAutoCapture, type NavigationKind } from './auto-capture'
|
|
16
|
+
import { installFrictionCapture, type FrictionCapture, type FrictionCaptureOptions } from './friction'
|
|
17
|
+
import { installSkeletonCapture, type SkeletonCapture } from './skeleton'
|
|
16
18
|
import { buildWebContext } from './web-context'
|
|
19
|
+
import { installProductChanges, type ProductChangesHandle } from './changes'
|
|
17
20
|
import type { HoldyOptions, Properties } from './types'
|
|
18
21
|
|
|
19
22
|
const ANON_STORAGE_KEY = 'holdy_anon_id'
|
|
@@ -24,10 +27,34 @@ export interface HoldyWebOptions extends HoldyOptions {
|
|
|
24
27
|
* Desligue só se o produto já chama `page()` na mão em cada tela.
|
|
25
28
|
*/
|
|
26
29
|
autoCapture?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Capturar fricção sozinho · default true. Emite `$dead_click`, `$rage_click`,
|
|
32
|
+
* `$form_abandon`, `$js_error`, `$slow_request`, `$nav_loop` e `$http_error`
|
|
33
|
+
* (requisição com status ≥ 400 ou sem resposta, e a própria página que abre
|
|
34
|
+
* com erro) — eventos determinísticos, sem DOM nem replay. Objeto ajusta os
|
|
35
|
+
* limiares; a URL da Holdy nunca conta como erro.
|
|
36
|
+
*/
|
|
37
|
+
friction?: boolean | Omit<FrictionCaptureOptions, 'onFriction'>
|
|
38
|
+
/**
|
|
39
|
+
* Aplicar as mudanças de texto e dica que a org pôs no ar na Holdy · default
|
|
40
|
+
* true. Parte das contas fica de fora por sorteio pra medir; sem conta
|
|
41
|
+
* identificada (`group()` ou `accountKey`) nada é aplicado.
|
|
42
|
+
*/
|
|
43
|
+
productChanges?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Mandar o esqueleto da página · default true. Uma vez por (rota, estrutura)
|
|
46
|
+
* por aba: `$page_skeleton` com os elementos interativos (tag, role,
|
|
47
|
+
* data-testid, name, aria-label, tipo, posição relativa). Sem texto livre,
|
|
48
|
+
* sem valor de input, sem pixel.
|
|
49
|
+
*/
|
|
50
|
+
skeleton?: boolean
|
|
27
51
|
}
|
|
28
52
|
|
|
29
53
|
export class HoldyWeb extends HoldyCore {
|
|
30
54
|
private teardownAutoCapture: (() => void) | null = null
|
|
55
|
+
private friction: FrictionCapture | null = null
|
|
56
|
+
private changes: ProductChangesHandle | null = null
|
|
57
|
+
private skeleton: SkeletonCapture | null = null
|
|
31
58
|
|
|
32
59
|
constructor(opts: HoldyWebOptions) {
|
|
33
60
|
super('sdk_web', opts)
|
|
@@ -40,9 +67,40 @@ export class HoldyWeb extends HoldyCore {
|
|
|
40
67
|
})
|
|
41
68
|
}
|
|
42
69
|
|
|
70
|
+
if (opts.friction !== false) {
|
|
71
|
+
const frictionOpts = typeof opts.friction === 'object' ? opts.friction : {}
|
|
72
|
+
this.friction = installFrictionCapture({
|
|
73
|
+
...frictionOpts,
|
|
74
|
+
ignoreUrls: [...(frictionOpts.ignoreUrls ?? []), this.baseUrl],
|
|
75
|
+
onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (opts.skeleton !== false) {
|
|
80
|
+
this.skeleton = installSkeletonCapture({
|
|
81
|
+
onSkeleton: (skeleton) => this.track('$page_skeleton', { ...skeleton }),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
43
85
|
if (opts.autoCapture !== false) {
|
|
44
86
|
this.teardownAutoCapture = installAutoCapture({
|
|
45
|
-
onNavigate: (kind: NavigationKind) =>
|
|
87
|
+
onNavigate: (kind: NavigationKind) => {
|
|
88
|
+
this.page(undefined, { navigation: kind })
|
|
89
|
+
const path = typeof location !== 'undefined' ? location.pathname : '/'
|
|
90
|
+
this.friction?.onNavigate(path)
|
|
91
|
+
this.changes?.onNavigate()
|
|
92
|
+
this.skeleton?.onNavigate(path)
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.productChanges !== false && typeof window !== 'undefined') {
|
|
98
|
+
this.changes = installProductChanges({
|
|
99
|
+
baseUrl: this.baseUrl,
|
|
100
|
+
apiKey: this.apiKey,
|
|
101
|
+
getAccountKey: () => this.accountKey,
|
|
102
|
+
track: (name, properties) => this.track(name, properties),
|
|
103
|
+
debug: opts.debug,
|
|
46
104
|
})
|
|
47
105
|
}
|
|
48
106
|
}
|
|
@@ -62,6 +120,11 @@ export class HoldyWeb extends HoldyCore {
|
|
|
62
120
|
})
|
|
63
121
|
}
|
|
64
122
|
|
|
123
|
+
override group(accountKey: string, traits?: Properties): void {
|
|
124
|
+
super.group(accountKey, traits)
|
|
125
|
+
this.changes?.onNavigate()
|
|
126
|
+
}
|
|
127
|
+
|
|
65
128
|
reset(): void {
|
|
66
129
|
this.userId = null
|
|
67
130
|
this.accountKey = null
|
|
@@ -71,12 +134,19 @@ export class HoldyWeb extends HoldyCore {
|
|
|
71
134
|
// storage indisponível (SSR/privacy mode) — ok
|
|
72
135
|
}
|
|
73
136
|
this.anonymousId = resolveAnonymousId()
|
|
137
|
+
this.changes?.onNavigate()
|
|
74
138
|
}
|
|
75
139
|
|
|
76
140
|
/** Flush final + desinstala o auto-capture (devolve `history` ao original). */
|
|
77
141
|
override async shutdown(): Promise<void> {
|
|
78
142
|
this.teardownAutoCapture?.()
|
|
79
143
|
this.teardownAutoCapture = null
|
|
144
|
+
this.friction?.teardown()
|
|
145
|
+
this.friction = null
|
|
146
|
+
this.changes?.teardown()
|
|
147
|
+
this.changes = null
|
|
148
|
+
this.skeleton?.teardown()
|
|
149
|
+
this.skeleton = null
|
|
80
150
|
await super.shutdown()
|
|
81
151
|
}
|
|
82
152
|
|