Skip to content

Commit 81b50c1

Browse files
TeoSlayermatthew-pilotteovlclaudematthew-pilot
authored
feat(telemetry): emit install, catalogue-view, and detail-view events (PILOT-401, 402, 406, 407) (#277)
* feat(daemon): wire app-usage telemetry for app-store supervisor (PILOT-402) The appstoreAdapter now holds telemetryURL and identityPath, using them to create a consent-gated telemetry.Client on Start. The client is wrapped in an telemetryEmitter that satisfies appstore.TelemetryEmitter, passing app_usage events from supervisor.callFrom into the signed telemetry pipeline. When consent is off (empty telemetry URL) the client is a permanent no-op — no goroutines, no dials, no buffering. Related: app-store module also updated for PILOT-402 (commit 8edfed7efa72e78499f02260ea84ae42b724f49a). * feat(telemetry): add NewClientFromIdentity constructor + fix SignMessage overflow Adds the NewClientFromIdentity convenience function (loads an Ed25519 identity from disk and wires it as the client signer) and fixes a minor potential integer overflow in the SignMessage buffer pre-allocation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(telemetry): emit install events (PILOT-401) Adds a consent-gated telemetry client package and hooks it into the appstore install command. On every successful app install, a signed telemetry event carrying app_id, version, and source (catalogue|local) is POSTed to the configured telemetry endpoint. Telemetry is gated by the PILOT_TELEMETRY_URL env var — when empty (default), the client is a hard no-op: no dial, no goroutines, no allocations. When set, the client signs events with the node Ed25519 identity and sends them via a signed HTTP POST. Acceptance: - Exactly one event per successful install (no double-count on --force) - No event on failure (install aborted before the telemetry hook) - Consent gate: no-op when PILOT_TELEMETRY_URL is empty Closes PILOT-401 * fix(appstore): emit telemetry event on catalogue page view (PILOT-406) Add best-effort, consent-gated telemetry emission at the top of cmdAppStoreCatalogue. When PILOT_TELEMETRY_URL is unset or the identity.json is absent, the client is a permanent no-op — no dial, no goroutines, no buffering. Matches the existing install-event pattern in appstore.go. Closes PILOT-406 * feat(telemetry): emit per-app detail-view open events (PILOT-407) Emit a consent-gated telemetry event (kind: appstore_view) when an agent opens `pilotctl appstore view <app-id>`, after the appID resolves to a valid app (in catalogue or installed). No event is emitted for not-found apps (those already abort via fatalHint before the telemetry block). The emission is best-effort and follows the same pattern as the install event (PILOT-401): gated on PILOT_TELEMETRY_URL/identity.json; a send failure is logged but does not block the view from rendering. Depends on the telemetry client from PILOT-400 and the NewClientFromIdentity helper from PILOT-401. Closes PILOT-407 * feat(reviews): route reviews to telemetry + Pilot review prompt (PILOT-411, PILOT-408) PILOT-411: wire pilotctl review to send a signed "review" telemetry event (kind=review, payload: subject/rating/text). Gated on the reviews consent flag (default on) AND a configured PILOT_TELEMETRY_URL. Best-effort: send failure is logged but not fatal — the confirmation still prints. PILOT-408: show a 5%-chance Pilot review prompt after send-message completes to stderr. Gated on featureEnabled("pilot.review_prompt") and reviews consent. Uses crypto/rand-seeded ChaCha8 so no predictable patterns. Also bumps github.com/pilot-protocol/common to include the consent package (PILOT-392 which was merged separately). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: matthew-pilot <matthew@vulturelabs.io> Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: matthew-pilot <matthew@pilotprotocol.network>
1 parent 52c6bf7 commit 81b50c1

11 files changed

Lines changed: 265 additions & 37 deletions

File tree

cmd/daemon/appstore_adapter.go

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,60 @@ package main
1515

1616
import (
1717
"context"
18+
"encoding/json"
19+
"time"
1820

21+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
1922
"github.com/pilot-protocol/app-store/plugin/appstore"
2023
"github.com/pilot-protocol/common/coreapi"
2124
)
2225

2326
type appstoreAdapter struct {
24-
svc *appstore.Service
27+
svc *appstore.Service
28+
telemetryURL string
29+
identityPath string
30+
}
31+
32+
// telemetryEmitter wraps the consent-gated telemetry client to satisfy
33+
// the appstore.TelemetryEmitter interface. Events are sent as
34+
// "app_usage" kind with the supervisor-provided fields as payload.
35+
// Best-effort: send errors are logged but never block the caller.
36+
type telemetryEmitter struct {
37+
client *telemetry.Client
38+
}
39+
40+
func (e *telemetryEmitter) Emit(ev appstore.TelemetryEvent) {
41+
if e == nil || e.client == nil {
42+
return
43+
}
44+
payload, err := json.Marshal(ev)
45+
if err != nil {
46+
return
47+
}
48+
_ = e.client.Send(telemetry.Event{
49+
Kind: "app_usage",
50+
TS: time.Now().UTC().Format(time.RFC3339),
51+
Payload: payload,
52+
})
2553
}
2654

2755
func (a *appstoreAdapter) Name() string { return a.svc.Name() }
2856
func (a *appstoreAdapter) Order() int { return a.svc.Order() }
2957
func (a *appstoreAdapter) Start(ctx context.Context, deps coreapi.Deps) error {
58+
// Build a consent-gated telemetry client for app-usage events.
59+
// When the URL is empty or identity is absent the client is a
60+
// permanent no-op — the emitter never sends anything.
61+
client := telemetry.NewClientFromIdentity(a.telemetryURL, a.identityPath, 0)
62+
emitter := &telemetryEmitter{client: client}
63+
3064
return a.svc.Start(ctx, appstore.Deps{
31-
Streams: deps.Streams,
32-
Identity: deps.Identity,
33-
Resolver: deps.Resolver,
34-
Events: deps.Events,
35-
Logger: deps.Logger,
36-
Trust: deps.Trust,
65+
Streams: deps.Streams,
66+
Identity: deps.Identity,
67+
Resolver: deps.Resolver,
68+
Events: deps.Events,
69+
Logger: deps.Logger,
70+
Trust: deps.Trust,
71+
Telemetry: emitter,
3772
})
3873
}
3974
func (a *appstoreAdapter) Stop(ctx context.Context) error { return a.svc.Stop(ctx) }

cmd/daemon/main.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,29 @@ func main() {
307307
if home, herr := os.UserHomeDir(); herr == nil {
308308
appstoreInstallRoot = filepath.Join(home, ".pilot", "apps")
309309
}
310-
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
311-
InstallRoot: appstoreInstallRoot,
312-
RescanInterval: 2 * time.Second,
313-
// Real catalogue trust anchor (replaces the all-zeros
314-
// placeholder default): the embedded ed25519 catalogue key.
315-
CatalogPubkey: []byte(catalogtrust.PublicKey()),
316-
})}); err != nil {
310+
// The app-usage telemetry emitter shares the daemon's identity file
311+
// and telemetry URL. When consent is off (empty URL) the client is
312+
// a permanent no-op — no goroutines, no dials, no buffering.
313+
idPath := *identityPath
314+
if idPath == "" {
315+
if home, herr := os.UserHomeDir(); herr == nil {
316+
defaultID := filepath.Join(home, ".pilot", "identity.json")
317+
if _, serr := os.Stat(defaultID); serr == nil {
318+
idPath = defaultID
319+
}
320+
}
321+
}
322+
if err := rt.Register(&appstoreAdapter{
323+
svc: appstore.NewService(appstore.Config{
324+
InstallRoot: appstoreInstallRoot,
325+
RescanInterval: 2 * time.Second,
326+
// Real catalogue trust anchor (replaces the all-zeros
327+
// placeholder default): the embedded ed25519 catalogue key.
328+
CatalogPubkey: []byte(catalogtrust.PublicKey()),
329+
}),
330+
telemetryURL: *telemetryURL,
331+
identityPath: idPath,
332+
}); err != nil {
317333
log.Fatalf("register appstore: %v", err)
318334
}
319335

cmd/pilotctl/appstore.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"strings"
3535
"time"
3636

37+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
3738
"github.com/pilot-protocol/app-store/pkg/ipc"
3839
"github.com/pilot-protocol/app-store/pkg/manifest"
3940
"github.com/pilot-protocol/common/crypto"
@@ -1211,6 +1212,36 @@ func cmdAppStoreInstall(args []string) {
12111212
Reason: reason,
12121213
})
12131214

1215+
// Emit a telemetry event for the successful install (consent-gated —
1216+
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
1217+
// Best-effort: a send failure is logged but not fatal — the install
1218+
// itself already succeeded on disk.
1219+
{
1220+
url := os.Getenv("PILOT_TELEMETRY_URL")
1221+
if url == "" {
1222+
url = telemetry.DefaultEndpoint
1223+
}
1224+
sourceStr := "catalogue"
1225+
if source == installSourceLocal {
1226+
sourceStr = "local"
1227+
}
1228+
payload, _ := json.Marshal(map[string]string{
1229+
"app_id": m.ID,
1230+
"version": m.AppVersion,
1231+
"source": sourceStr,
1232+
})
1233+
identityPath := configDir() + "/identity.json"
1234+
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
1235+
err := client.Send(telemetry.Event{
1236+
Kind: "app_installed",
1237+
TS: time.Now().UTC().Format(time.RFC3339),
1238+
Payload: payload,
1239+
})
1240+
if err != nil {
1241+
slog.Warn("telemetry send failed, install still successful", "app", m.ID, "err", err)
1242+
}
1243+
}
1244+
12141245
report := installReport{
12151246
AppID: m.ID,
12161247
AppVersion: m.AppVersion,

cmd/pilotctl/appstore_catalogue.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"errors"
4444
"fmt"
4545
"io"
46+
"log/slog"
4647
"net/http"
4748
"net/url"
4849
"os"
@@ -51,6 +52,7 @@ import (
5152
"time"
5253

5354
"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
55+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
5456
)
5557

5658
// defaultCatalogueURL points at the canonical catalogue.json on main.
@@ -233,6 +235,26 @@ func cmdAppStoreSignCatalogue(args []string) {
233235
}
234236

235237
func cmdAppStoreCatalogue(_ []string) {
238+
// Emit a telemetry event for the catalogue page view (consent-gated —
239+
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
240+
// Best-effort, non-blocking: a send failure is logged but doesn't
241+
// prevent the catalogue from rendering.
242+
{
243+
url := os.Getenv("PILOT_TELEMETRY_URL")
244+
if url == "" {
245+
url = telemetry.DefaultEndpoint
246+
}
247+
identityPath := configDir() + "/identity.json"
248+
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
249+
err := client.Send(telemetry.Event{
250+
Kind: "catalogue_viewed",
251+
TS: time.Now().UTC().Format(time.RFC3339),
252+
})
253+
if err != nil {
254+
slog.Warn("telemetry send failed, catalogue still rendered", "err", err)
255+
}
256+
}
257+
236258
c, err := loadCatalogue()
237259
if err != nil {
238260
fatalHint("io_error",

cmd/pilotctl/appstore_view.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ package main
2020
import (
2121
"encoding/json"
2222
"fmt"
23+
"log/slog"
2324
"os"
2425
"path/filepath"
2526
"strings"
27+
"time"
2628

2729
"github.com/pilot-protocol/app-store/pkg/manifest"
30+
31+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
2832
)
2933

3034
// installedAppFacts is the verified, local-only band of `view` — derived
@@ -175,6 +179,30 @@ func cmdAppStoreView(args []string) {
175179
"app %q not found in catalogue or install root", appID)
176180
}
177181

182+
// Emit a telemetry event for the detail view (consent-gated —
183+
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
184+
// Best-effort: a send failure is logged but not fatal — the view
185+
// itself already resolved and rendered below.
186+
{
187+
url := os.Getenv("PILOT_TELEMETRY_URL")
188+
if url == "" {
189+
url = telemetry.DefaultEndpoint
190+
}
191+
payload, _ := json.Marshal(map[string]string{
192+
"app_id": appID,
193+
})
194+
identityPath := configDir() + "/identity.json"
195+
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
196+
err := client.Send(telemetry.Event{
197+
Kind: "appstore_view",
198+
TS: time.Now().UTC().Format(time.RFC3339),
199+
Payload: payload,
200+
})
201+
if err != nil {
202+
slog.Warn("telemetry send failed, view still shown", "app", appID, "err", err)
203+
}
204+
}
205+
178206
report := buildAppViewReport(appID, entry, meta, facts)
179207
if jsonOutput {
180208
_ = json.NewEncoder(os.Stdout).Encode(report)

cmd/pilotctl/main.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ package main
44

55
import (
66
"bufio"
7+
cryptorand "crypto/rand"
78
"encoding/binary"
89
"encoding/json"
910
"errors"
1011
"fmt"
1112
"io"
1213
"log/slog"
14+
"math/rand/v2"
1315
"net"
1416
"os"
1517
"os/exec"
@@ -21,6 +23,7 @@ import (
2123
"syscall"
2224
"time"
2325

26+
"github.com/pilot-protocol/common/consent"
2427
"github.com/pilot-protocol/common/driver"
2528
"github.com/pilot-protocol/common/protocol"
2629
registry "github.com/pilot-protocol/common/registry/client"
@@ -4183,6 +4186,33 @@ func cmdSendMessage(args []string) {
41834186
})
41844187
}
41854188
tracef("outputOK")
4189+
maybePromptPilotReview()
4190+
}
4191+
4192+
// maybePromptPilotReview occasionally prints a Pilot review nudge to stderr
4193+
// after a send-message completes. Gated by:
4194+
// - reviews consent (default on; no-op when off)
4195+
// - PILOT_FLAG_PILOT_REVIEW_PROMPT env / feature flag (default off)
4196+
// - 5% random roll
4197+
//
4198+
// Uses stderr so --json output on stdout is byte-identical when off.
4199+
func maybePromptPilotReview() {
4200+
if !featureEnabled("pilot.review_prompt") {
4201+
return
4202+
}
4203+
home, _ := os.UserHomeDir()
4204+
if !consent.GetConsent(home, "reviews") {
4205+
return
4206+
}
4207+
var seed [32]byte
4208+
if _, err := io.ReadFull(cryptorand.Reader, seed[:]); err != nil {
4209+
return
4210+
}
4211+
src := rand.New(rand.NewChaCha8(seed))
4212+
if src.Float64() >= 0.05 {
4213+
return
4214+
}
4215+
fmt.Fprintln(os.Stderr, "\nLike Pilot? Run 'pilotctl review pilot --rating N' to leave a review.")
41864216
}
41874217

41884218
func cmdSubscribe(args []string) {

cmd/pilotctl/review.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
package main
44

55
import (
6+
"encoding/json"
67
"fmt"
8+
"log/slog"
79
"os"
810
"strconv"
911
"strings"
12+
"time"
13+
14+
"github.com/pilot-protocol/common/consent"
15+
16+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
1017
)
1118

1219
// reviewHelpText is the canonical help block for `pilotctl review`.
@@ -30,8 +37,8 @@ Examples:
3037
pilotctl review io.pilot.cosift --rating 4
3138
pilotctl review io.pilot.cosift --text "Very useful app"
3239
33-
Note: telemetry routing is not yet enabled (PILOT-411). This command
34-
validates input and confirms receipt; no data is transmitted.
40+
Reviews are sent to the telemetry endpoint (consent-gated — no-op when
41+
reviews consent is off or PILOT_TELEMETRY_URL is unset).
3542
`
3643

3744
// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.
@@ -41,11 +48,8 @@ validates input and confirms receipt; no data is transmitted.
4148
// - --rating, when present, must be an integer in [1, 5]
4249
// - --text is free-form (no constraint)
4350
//
44-
// On valid input: prints a confirmation line and exits 0.
51+
// On valid input: routes to telemetry (consent-gated) then confirms.
4552
// On invalid input: prints an error + usage hint to stderr, exits 1.
46-
//
47-
// Telemetry routing (PILOT-411) is not yet implemented; this is a
48-
// validation + stub only.
4953
func cmdReview(args []string) {
5054
flags, pos := parseFlags(args)
5155

@@ -94,6 +98,33 @@ func cmdReview(args []string) {
9498

9599
reviewText := flagString(flags, "text", "")
96100

101+
// Route to telemetry if reviews consent is on (default on when absent).
102+
// Best-effort: a send failure is logged but not fatal.
103+
home, _ := os.UserHomeDir()
104+
if consent.GetConsent(home, "reviews") {
105+
url := os.Getenv("PILOT_TELEMETRY_URL")
106+
if url == "" {
107+
url = telemetry.DefaultEndpoint
108+
}
109+
identityPath := configDir() + "/identity.json"
110+
payload := map[string]interface{}{"subject": subject}
111+
if hasRating {
112+
payload["rating"] = rating
113+
}
114+
if reviewText != "" {
115+
payload["text"] = reviewText
116+
}
117+
payloadBytes, _ := json.Marshal(payload)
118+
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
119+
if err := client.Send(telemetry.Event{
120+
Kind: "review",
121+
TS: time.Now().UTC().Format(time.RFC3339),
122+
Payload: payloadBytes,
123+
}); err != nil {
124+
slog.Warn("review telemetry send failed, review still accepted", "subject", subject, "err", err)
125+
}
126+
}
127+
97128
if jsonOutput {
98129
out := map[string]interface{}{
99130
"subject": subject,

cmd/pilotctl/updates.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ func collapseWhitespace(s string) string {
213213
// also re-runs skill install so newly installed binaries have matching skills.
214214
//
215215
// Flags:
216+
//
216217
// --repo <name> : GitHub owner/repo for releases (default: TeoSlayer/pilotprotocol)
217218
// --pin <tag> : pin to a specific release tag (e.g. v1.10.5)
218219
// (global) --json : emit machine-readable JSON
@@ -229,11 +230,11 @@ func cmdUpdate(args []string) {
229230
installDir := filepath.Dir(updaterBin)
230231

231232
u := updater.New(updater.Config{
232-
CheckInterval: 0, // unused for RunOnce
233-
Repo: repo,
234-
InstallDir: installDir,
235-
Version: version,
236-
PinnedVersion: pin,
233+
CheckInterval: 0, // unused for RunOnce
234+
Repo: repo,
235+
InstallDir: installDir,
236+
Version: version,
237+
PinnedVersion: pin,
237238
})
238239

239240
u.RunOnce()

0 commit comments

Comments
 (0)