happyskills 1.7.1 → 1.8.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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/commands/install.js +4 -1
- package/src/engine/installer.js +22 -0
- package/src/utils/analytics.js +20 -10
- package/src/utils/analytics.test.js +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.8.0] - 2026-06-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Record installs via a cache-immune `install.completed` beacon. The install engine now emits one `install.completed` event per newly-installed skill — the root **and** each transitive dependency — batched into a single `POST /events`, carrying the skill's `repo_id`, version, and a root-vs-dependency flag. Because it fires from the engine, `setup` is now counted too (previously it emitted nothing). This replaces the per-command `install.completed` event and is the new source of truth for install/download counts, fixing the under-count caused by CDN cache hits on the clone endpoint silently skipping the old counting path. Fire-and-forget — a telemetry failure never blocks or fails an install; an already-up-to-date (no-op) install emits nothing. Requires API v5.5.0+ for `repo_id` stamping and download-count crediting; against older APIs the events are still sent (forward-compatible).
|
|
15
|
+
|
|
10
16
|
## [1.7.1] - 2026-06-04
|
|
11
17
|
|
|
12
18
|
### Fixed
|
package/package.json
CHANGED
package/src/commands/install.js
CHANGED
|
@@ -214,7 +214,10 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
214
214
|
emit_analytics('install.failed', { error_code: 'INSTALL_FAILED', cli_version: CLI_VERSION })
|
|
215
215
|
} else {
|
|
216
216
|
results.push({ skill, result })
|
|
217
|
-
|
|
217
|
+
// install.completed is now emitted per-skill from the install engine
|
|
218
|
+
// (cli/src/engine/installer.js, spec 260604-01) — carrying repo_id,
|
|
219
|
+
// version, and a root-vs-dependency flag — so setup (which bypasses
|
|
220
|
+
// this command) is covered too. No per-command emit here.
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
|
package/src/engine/installer.js
CHANGED
|
@@ -16,6 +16,8 @@ const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('
|
|
|
16
16
|
const { is_skill_enabled } = require('../agents/status')
|
|
17
17
|
const { create_spinner } = require('../ui/spinner')
|
|
18
18
|
const { print_success, print_warn, print_info } = require('../ui/output')
|
|
19
|
+
const { emit_batch } = require('../utils/analytics')
|
|
20
|
+
const { CLI_VERSION } = require('../constants')
|
|
19
21
|
|
|
20
22
|
const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
|
|
21
23
|
const hint = dep.install_hint ? ` (install: ${dep.install_hint})` : ''
|
|
@@ -316,6 +318,26 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
// Cache-immune install counting (spec 260604-01). Fire one
|
|
322
|
+
// install.completed beacon per newly-installed skill — root AND each
|
|
323
|
+
// transitive dependency — batched into a single POST /events. This is the
|
|
324
|
+
// source of truth for install/download counts: it replaces the clone-
|
|
325
|
+
// endpoint side-effect that CloudFront cache HITs silently dropped. Only
|
|
326
|
+
// reached on a genuine install (no_op paths returned earlier and emit
|
|
327
|
+
// nothing). Fire-and-forget — never blocks or fails the install.
|
|
328
|
+
emit_batch(downloaded.map(({ pkg }) => {
|
|
329
|
+
const requested_by = updates[pkg.skill]?.requested_by || []
|
|
330
|
+
return {
|
|
331
|
+
event_type: 'install.completed',
|
|
332
|
+
repo_id: pkg.repo_id || null,
|
|
333
|
+
metadata: {
|
|
334
|
+
version: pkg.version,
|
|
335
|
+
dependency: !requested_by.includes('__root__'),
|
|
336
|
+
cli_version: CLI_VERSION,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}))
|
|
340
|
+
|
|
319
341
|
spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
|
|
320
342
|
|
|
321
343
|
const linked_count = downloaded.length - disabled_skills.size
|
package/src/utils/analytics.js
CHANGED
|
@@ -7,25 +7,35 @@
|
|
|
7
7
|
|
|
8
8
|
const { error: { catch_errors } } = require('puffy-core')
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const post_events = (events) => catch_errors('Failed to post analytics events', async () => {
|
|
11
11
|
const { post } = require('../api/client')
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
metadata: metadata || {},
|
|
12
|
+
const stamped = events.map(ev => ({
|
|
13
|
+
event_type: ev.event_type,
|
|
14
|
+
...(ev.repo_id ? { repo_id: ev.repo_id } : {}),
|
|
15
|
+
metadata: ev.metadata || {},
|
|
16
16
|
client_ts: Date.now(),
|
|
17
|
-
}
|
|
17
|
+
}))
|
|
18
|
+
// Use unwrap:false so we don't trip on the empty body.
|
|
19
|
+
await post('/events', { events: stamped }, { auth: true, unwrap: false })
|
|
18
20
|
})
|
|
19
21
|
|
|
20
22
|
// Caller-friendly wrapper. Errors are swallowed — engagement telemetry must
|
|
21
23
|
// never block the user.
|
|
22
|
-
const emit = (event_type, metadata) => {
|
|
24
|
+
const emit = (event_type, metadata) => emit_batch([{ event_type, metadata }])
|
|
25
|
+
|
|
26
|
+
// Batched emit — sends N events in a single POST /events. Used by the install
|
|
27
|
+
// engine to record one install.completed per installed skill (root + each
|
|
28
|
+
// transitive dependency) in one request (spec 260604-01). Each event may carry
|
|
29
|
+
// a top-level `repo_id` (UUID); per-event metadata is passed through as-is.
|
|
30
|
+
const emit_batch = (events) => {
|
|
31
|
+
if (!Array.isArray(events) || events.length === 0) return
|
|
23
32
|
// Don't return the promise — fire-and-forget. Errors stay quiet.
|
|
24
|
-
|
|
33
|
+
post_events(events).then(([errors]) => {
|
|
25
34
|
if (errors && process.env.HAPPYSKILLS_DEBUG) {
|
|
26
|
-
|
|
35
|
+
const types = events.map(e => e.event_type).join(',')
|
|
36
|
+
process.stderr.write(`[analytics] ${types} failed: ${errors[0]?.message || 'unknown'}\n`)
|
|
27
37
|
}
|
|
28
38
|
}).catch(() => {})
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
module.exports = { emit }
|
|
41
|
+
module.exports = { emit, emit_batch }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Run with: node --test src/utils/analytics.test.js (also covered by `npm test`)
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for the batched analytics emitter (spec 260604-01). The install
|
|
4
|
+
// engine fires one install.completed event per installed skill (root + each
|
|
5
|
+
// dependency) batched into a SINGLE POST /events, with repo_id riding the
|
|
6
|
+
// top-level event field and version/dependency/cli_version in metadata. The
|
|
7
|
+
// emitter is fire-and-forget — it must never throw into the caller.
|
|
8
|
+
|
|
9
|
+
const { describe, it, afterEach } = require('node:test')
|
|
10
|
+
const assert = require('node:assert')
|
|
11
|
+
|
|
12
|
+
// Stub the API client before requiring the module under test, so we capture the
|
|
13
|
+
// exact POST payload emit_batch produces. analytics.js lazy-requires the client
|
|
14
|
+
// inside the post, so seeding require.cache here is enough.
|
|
15
|
+
const stub_client = (calls) => {
|
|
16
|
+
const client_path = require.resolve('../api/client')
|
|
17
|
+
require.cache[client_path] = {
|
|
18
|
+
id: client_path,
|
|
19
|
+
filename: client_path,
|
|
20
|
+
loaded: true,
|
|
21
|
+
exports: {
|
|
22
|
+
post: async (path, body, opts) => { calls.push({ path, body, opts }); return [null, null] },
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
delete require.cache[require.resolve('./analytics')]
|
|
26
|
+
return require('./analytics')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const restore = () => {
|
|
30
|
+
delete require.cache[require.resolve('../api/client')]
|
|
31
|
+
delete require.cache[require.resolve('./analytics')]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// emit_batch is fire-and-forget — give the microtask/promise chain a tick.
|
|
35
|
+
const tick = () => new Promise(r => setImmediate(r))
|
|
36
|
+
|
|
37
|
+
describe('emit_batch — install.completed beacon', () => {
|
|
38
|
+
afterEach(restore)
|
|
39
|
+
|
|
40
|
+
it('sends ONE POST /events for N skills, repo_id top-level, version+dependency in metadata', async () => {
|
|
41
|
+
const calls = []
|
|
42
|
+
const { emit_batch } = stub_client(calls)
|
|
43
|
+
emit_batch([
|
|
44
|
+
{ event_type: 'install.completed', repo_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', metadata: { version: '1.0.0', dependency: false, cli_version: '9.9.9' } },
|
|
45
|
+
{ event_type: 'install.completed', repo_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', metadata: { version: '2.0.0', dependency: true, cli_version: '9.9.9' } },
|
|
46
|
+
])
|
|
47
|
+
await tick()
|
|
48
|
+
|
|
49
|
+
assert.equal(calls.length, 1, 'a constellation install is a single batched request')
|
|
50
|
+
assert.equal(calls[0].path, '/events')
|
|
51
|
+
assert.equal(calls[0].opts.auth, true)
|
|
52
|
+
const evts = calls[0].body.events
|
|
53
|
+
assert.equal(evts.length, 2)
|
|
54
|
+
assert.equal(evts[0].repo_id, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
|
55
|
+
assert.equal(evts[0].metadata.dependency, false)
|
|
56
|
+
assert.equal(evts[0].metadata.version, '1.0.0')
|
|
57
|
+
assert.equal(evts[1].repo_id, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
|
|
58
|
+
assert.equal(evts[1].metadata.dependency, true)
|
|
59
|
+
assert.ok(typeof evts[0].client_ts === 'number', 'each event is timestamped')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('no-ops on an empty batch (zero installed skills → no request)', async () => {
|
|
63
|
+
const calls = []
|
|
64
|
+
const { emit_batch } = stub_client(calls)
|
|
65
|
+
emit_batch([])
|
|
66
|
+
await tick()
|
|
67
|
+
assert.equal(calls.length, 0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('omits repo_id from the wire when it is null/absent', async () => {
|
|
71
|
+
const calls = []
|
|
72
|
+
const { emit_batch } = stub_client(calls)
|
|
73
|
+
emit_batch([{ event_type: 'install.completed', repo_id: null, metadata: { version: '1.0.0' } }])
|
|
74
|
+
await tick()
|
|
75
|
+
assert.equal('repo_id' in calls[0].body.events[0], false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('swallows a client error — telemetry never throws into the install flow', async () => {
|
|
79
|
+
const client_path = require.resolve('../api/client')
|
|
80
|
+
require.cache[client_path] = {
|
|
81
|
+
id: client_path, filename: client_path, loaded: true,
|
|
82
|
+
exports: { post: async () => { throw new Error('network down') } },
|
|
83
|
+
}
|
|
84
|
+
delete require.cache[require.resolve('./analytics')]
|
|
85
|
+
const { emit_batch } = require('./analytics')
|
|
86
|
+
assert.doesNotThrow(() => emit_batch([{ event_type: 'install.completed', metadata: {} }]))
|
|
87
|
+
await tick()
|
|
88
|
+
})
|
|
89
|
+
})
|