tackbox 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/js/report.js ADDED
@@ -0,0 +1,187 @@
1
+ // Browser report helper: empty DSN = log-only no-op.
2
+ // Mirrors the gmux/sts-wand pattern — single funnel for toast,
3
+ // Sentry capture, optional diagnostic stream.
4
+
5
+ const Sentry = require('@sentry/browser')
6
+
7
+ let ready = false
8
+ let rateWindow = 60_000
9
+ let flushTimeout = 2_000
10
+ const lastSent = new Map()
11
+
12
+ function init(opts = {}) {
13
+ const dsn = opts.dsn || ''
14
+ if (!dsn) {
15
+ if (!opts.silentMissing) {
16
+ console.log('[tackbox] WARN report: DSN unset, capture disabled, running log-only')
17
+ }
18
+ return
19
+ }
20
+ Sentry.init({
21
+ dsn,
22
+ release: opts.release,
23
+ environment: opts.environment,
24
+ debug: !!opts.debug,
25
+ defaultIntegrations: false,
26
+ integrations: opts.integrations || [],
27
+ })
28
+ ready = true
29
+ if (opts.rateWindow > 0) rateWindow = opts.rateWindow
30
+ if (opts.flushTimeout > 0) flushTimeout = opts.flushTimeout
31
+ if (opts.verify) {
32
+ const ok = verify(opts.verifyTimeout || 3_000)
33
+ ok.then(success => {
34
+ if (success) console.log('[tackbox] report: capture verified, DSN=' + maskDSN(dsn))
35
+ else console.warn('[tackbox] report.Init verify: flush timeout, capture endpoint unreachable or rejecting')
36
+ })
37
+ return
38
+ }
39
+ console.log('[tackbox] report: capture enabled (unverified), DSN=' + maskDSN(dsn))
40
+ }
41
+
42
+ function isReady() { return ready }
43
+
44
+ async function verify(timeout) {
45
+ if (!ready) return false
46
+ Sentry.withScope(scope => {
47
+ scope.setLevel('info')
48
+ scope.setFingerprint(['report.startup'])
49
+ scope.setTag('healthcheck', 'true')
50
+ Sentry.captureMessage('report.Verify')
51
+ })
52
+ return await Sentry.flush(timeout)
53
+ }
54
+
55
+ async function flush(timeout) {
56
+ if (!ready) return
57
+ await Sentry.flush(timeout || flushTimeout)
58
+ }
59
+
60
+ function shouldDrop(key) {
61
+ if (!key) return false
62
+ const now = Date.now()
63
+ const prev = lastSent.get(key)
64
+ if (prev !== undefined && now - prev < rateWindow) return true
65
+ lastSent.set(key, now)
66
+ return false
67
+ }
68
+
69
+ // captureEvent is the gated Sentry sink: nothing before init or inside the
70
+ // rate window. Shared by emit (report*) and reportQuiet.
71
+ function captureEvent(level, msg, cause, tags, dedupKey) {
72
+ if (!ready || shouldDrop(dedupKey)) return
73
+ const causeErr = cause instanceof Error ? cause : (cause == null ? null : new Error(String(cause)))
74
+ Sentry.withScope(scope => {
75
+ scope.setLevel(level)
76
+ if (dedupKey) scope.setFingerprint([dedupKey])
77
+ if (tags) for (const k of Object.keys(tags)) scope.setTag(k, String(tags[k]))
78
+ Sentry.captureException(causeErr ? new Error(msg, { cause: causeErr }) : new Error(msg))
79
+ })
80
+ }
81
+
82
+ function emit(level, msg, cause, tags, dedupKey) {
83
+ console[level === 'error' ? 'error' : 'warn'](`[${level.toUpperCase()}] ${msg}:`, cause)
84
+ // D005: user lane delivers always, before the init + rate-window gate
85
+ dispatchEventSafely('tackbox:error', { msg, cause, tags, dedupKey, level })
86
+ captureEvent(level, msg, cause, tags, dedupKey)
87
+ }
88
+
89
+ function reportError(msg, cause, tags, dedupKey) {
90
+ emit('error', msg, cause, tags, dedupKey)
91
+ }
92
+
93
+ function reportWarn(msg, cause, tags, dedupKey) {
94
+ emit('warning', msg, cause, tags, dedupKey)
95
+ }
96
+
97
+ function reportSynthError(msg, tags, dedupKey) {
98
+ // synth has no caught error; null keeps the capture a plain Error(msg)
99
+ emit('error', msg, null, tags, dedupKey)
100
+ }
101
+
102
+ // reportQuiet: warning-level capture with no user lane. For background /
103
+ // self-healed / degraded-with-fallback failures.
104
+ function reportQuiet(msg, cause, tags, dedupKey) {
105
+ console.warn(`[QUIET] ${msg}:`, cause)
106
+ captureEvent('warning', msg, cause, tags, dedupKey)
107
+ }
108
+
109
+ // notify: user lane only, no capture and no rate-window state touched, so a
110
+ // following reportError/reportWarn with the same dedupKey still captures. For
111
+ // an expected environmental fault (the user lost connectivity). cause is the
112
+ // caught error the notice is about.
113
+ function notify(msg, cause, tags, dedupKey) {
114
+ console.warn(`[NOTICE] ${msg}:`, cause)
115
+ dispatchEventSafely('tackbox:error', { msg, cause, tags, dedupKey, level: 'notice' })
116
+ }
117
+
118
+ function reportPanic(name, recovered) {
119
+ const key = 'panic:' + name
120
+ console.error(`[FATAL] panic in ${name}:`, recovered)
121
+ dispatchEventSafely('tackbox:error', { msg: 'panic in ' + name, cause: recovered, tags: { source: name }, dedupKey: key, level: 'fatal' })
122
+ if (!ready || shouldDrop(key)) return
123
+ Sentry.withScope(scope => {
124
+ scope.setLevel('fatal')
125
+ scope.setTag('source', name)
126
+ scope.setFingerprint([key])
127
+ Sentry.captureException(recovered instanceof Error ? recovered : new Error(String(recovered)))
128
+ })
129
+ }
130
+
131
+ function setupGlobalHandlers() {
132
+ if (typeof window === 'undefined') return
133
+ window.addEventListener('error', e => {
134
+ reportError('uncaught global error from window', e.error || e.message, { source: 'window.error' }, 'global.uncaught')
135
+ })
136
+ window.addEventListener('unhandledrejection', e => {
137
+ reportError('unhandled promise rejection from window', e.reason, { source: 'window.unhandledrejection' }, 'global.unhandled')
138
+ })
139
+ }
140
+
141
+ function maskDSN(dsn) {
142
+ // no-report: malformed user DSN, opaque marker is the recovery
143
+ try {
144
+ const u = new URL(dsn)
145
+ return u.host + u.pathname
146
+ } catch (e) {
147
+ return '<malformed>'
148
+ }
149
+ }
150
+
151
+ let dispatching = false
152
+
153
+ function dispatchEventSafely(name, detail) {
154
+ if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return
155
+ // Re-entrancy guard: a throwing `tackbox:error` listener surfaces via
156
+ // window.onerror (the DOM routes listener failures there, not to dispatchEvent),
157
+ // which setupGlobalHandlers turns back into reportError -> dispatch on the same
158
+ // stack. Skip the nested dispatch so that cannot loop; sequential dispatches
159
+ // are unaffected (D005 deliver-always intact).
160
+ if (dispatching) {
161
+ console.warn('[tackbox] report: nested tackbox:error dispatch skipped (listener-failure re-entry)')
162
+ return
163
+ }
164
+ dispatching = true
165
+ // no-report: dispatch failure loses only the notice; the verb's local log already ran
166
+ try {
167
+ window.dispatchEvent(new CustomEvent(name, { detail }))
168
+ } catch (e) {
169
+ // dispatch failed
170
+ } finally {
171
+ dispatching = false
172
+ }
173
+ }
174
+
175
+ module.exports = {
176
+ init,
177
+ flush,
178
+ isReady,
179
+ verify,
180
+ reportError,
181
+ reportWarn,
182
+ reportQuiet,
183
+ reportSynthError,
184
+ notify,
185
+ reportPanic,
186
+ setupGlobalHandlers,
187
+ }