Skip to content

Commit b9d8d97

Browse files
matthew-pilotmatthew-pilot
andauthored
feat(telemetry): emit per-app usage events from supervisor callFrom (PILOT-402) (#20)
After every IPC call through supervisor.callFrom (the single chokepoint for CLI+daemon+cross-app dispatch), a telemetry usage event is emitted with app_id, method, caller_id, ok status, duration, and optional error. The supervisor accepts a TelemetryEmitter interface via Deps.Telemetry and defaults to a no-op when no emitter is wired. The web4 daemon adapter wraps the consent-gated telemetry.Client to emit app_usage events, sharing the daemon's identity file and telemetry URL. Closes PILOT-402 Co-authored-by: matthew-pilot <matthew@pilotprotocol.network>
1 parent cdba2f7 commit b9d8d97

3 files changed

Lines changed: 94 additions & 12 deletions

File tree

plugin/appstore/service.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,13 @@ func (s *Service) Order() int { return 120 }
135135
// Go's structural typing makes this work as long as the methods used here
136136
// are present on the real types.
137137
type Deps struct {
138-
Streams any // coreapi.Streams — Dial, Listen, SendDatagram
139-
Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign
140-
Resolver any
141-
Events any // coreapi.EventBus — Publish, Subscribe
142-
Logger any
143-
Trust any
138+
Streams any // coreapi.Streams — Dial, Listen, SendDatagram
139+
Identity any // coreapi.Identity — NodeID, Address, PublicKey, Sign
140+
Resolver any
141+
Events any // coreapi.EventBus — Publish, Subscribe
142+
Logger any
143+
Trust any
144+
Telemetry TelemetryEmitter // optional; no-op when nil
144145
}
145146

146147
// Start scans InstallRoot for installed apps, verifies each binary's

plugin/appstore/supervisor.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ type supervisor struct {
171171
}
172172

173173
func newSupervisor(cfg Config, deps Deps, logger *log.Logger) *supervisor {
174+
// Default no-op telemetry when the daemon doesn't wire one.
175+
if deps.Telemetry == nil {
176+
deps.Telemetry = noopEmitter{}
177+
}
174178
return &supervisor{
175179
cfg: cfg,
176180
deps: deps,
@@ -1035,36 +1039,49 @@ func (s *supervisor) CallFrom(ctx context.Context, callerID, appID, method strin
10351039
// connection is dialed per-call — simple and lets the app's own
10361040
// concurrency handle multiple in-flight calls. Returns the typed gate
10371041
// errors above, or otherwise propagates the app's IPC response/error.
1042+
//
1043+
// After every call (success or failure) a telemetry usage event is
1044+
// emitted — best-effort, never blocks the caller.
10381045
func (s *supervisor) callFrom(ctx context.Context, callerID, appID, method string, args, out any) error {
10391046
s.mu.RLock()
10401047
app, ok := s.installed[appID]
10411048
caller, callerOK := s.installed[callerID]
10421049
ready := s.ready[appID]
10431050
s.mu.RUnlock()
10441051
if !ok {
1045-
return fmt.Errorf("%w: %s", ErrAppNotInstalled, appID)
1052+
err := fmt.Errorf("%w: %s", ErrAppNotInstalled, appID)
1053+
s.emitUsage(callerID, appID, method, false, 0, err.Error())
1054+
return err
10461055
}
10471056
// Broker-surface gate: only methods the app explicitly exposes are
10481057
// dispatchable, for every caller including the daemon itself.
10491058
if !app.Manifest.ExposesMethod(method) {
1050-
return fmt.Errorf("%w: %s.%s", ErrMethodNotExposed, appID, method)
1059+
err := fmt.Errorf("%w: %s.%s", ErrMethodNotExposed, appID, method)
1060+
s.emitUsage(callerID, appID, method, false, 0, err.Error())
1061+
return err
10511062
}
10521063
// Cross-app grant gate: a calling app must have declared an ipc.call
10531064
// grant targeting the specific method it wants to invoke. Trusted
10541065
// callers (empty callerID) skip this — they are the broker itself.
10551066
if callerID != "" {
10561067
if !callerOK {
1057-
return fmt.Errorf("%w: caller %q not installed", ErrGrantMissing, callerID)
1068+
err := fmt.Errorf("%w: caller %q not installed", ErrGrantMissing, callerID)
1069+
s.emitUsage(callerID, appID, method, false, 0, err.Error())
1070+
return err
10581071
}
10591072
target := appID + "." + method
10601073
if !caller.Manifest.HasGrant("ipc.call", target) {
1061-
return fmt.Errorf("%w: %s -> %s", ErrGrantMissing, callerID, target)
1074+
err := fmt.Errorf("%w: %s -> %s", ErrGrantMissing, callerID, target)
1075+
s.emitUsage(callerID, appID, method, false, 0, err.Error())
1076+
return err
10621077
}
10631078
}
10641079
if !ready {
10651080
// Give it a brief moment in case it just spawned.
10661081
if !s.awaitReady(ctx, appID, 1*time.Second) {
1067-
return fmt.Errorf("%w: %s", ErrAppNotReady, appID)
1082+
err := fmt.Errorf("%w: %s", ErrAppNotReady, appID)
1083+
s.emitUsage(callerID, appID, method, false, 0, err.Error())
1084+
return err
10681085
}
10691086
}
10701087

@@ -1082,7 +1099,39 @@ func (s *supervisor) callFrom(ctx context.Context, callerID, appID, method strin
10821099
if dl, ok := ctx.Deadline(); ok {
10831100
_ = conn.SetDeadline(dl)
10841101
}
1085-
return ipc.Call(conn, method, args, out)
1102+
1103+
start := time.Now()
1104+
err = ipc.Call(conn, method, args, out)
1105+
dur := time.Since(start).Milliseconds()
1106+
if err != nil {
1107+
s.emitUsage(callerID, appID, method, false, dur, err.Error())
1108+
} else {
1109+
s.emitUsage(callerID, appID, method, true, dur, "")
1110+
}
1111+
return err
1112+
}
1113+
1114+
// emitUsage best-effort fires a telemetry event into the Deps.Telemetry
1115+
// emitter. It never blocks and recovers from emitter panics so a buggy
1116+
// downstream never sinks the supervisor call path.
1117+
func (s *supervisor) emitUsage(callerID, appID, method string, ok bool, durMs int64, errMsg string) {
1118+
t := s.deps.Telemetry
1119+
if t == nil {
1120+
return
1121+
}
1122+
defer func() {
1123+
if r := recover(); r != nil {
1124+
s.logger.Printf("telemetry emitter panicked: %v", r)
1125+
}
1126+
}()
1127+
t.Emit(TelemetryEvent{
1128+
AppID: appID,
1129+
Method: method,
1130+
CallerID: callerID,
1131+
OK: ok,
1132+
DurMs: durMs,
1133+
ErrMsg: errMsg,
1134+
})
10861135
}
10871136

10881137
// awaitReady polls the ready bit for app until it flips true or the

plugin/appstore/telemetry.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package appstore
4+
5+
// TelemetryEvent captures one app-usage event that the supervisor emits
6+
// after every successful (or failed) IPC call through callFrom. The
7+
// daemon's telemetry client converts these to signed HTTP POSTs.
8+
//
9+
// Fields match what the telemetry endpoint expects for "app_usage" kind
10+
// events. CallerID is empty for trusted daemon/pilotctl calls and
11+
// non-empty for cross-app calls.
12+
type TelemetryEvent struct {
13+
AppID string `json:"app_id"`
14+
Method string `json:"method"`
15+
CallerID string `json:"caller_id,omitempty"`
16+
OK bool `json:"ok"`
17+
DurMs int64 `json:"dur_ms"`
18+
ErrMsg string `json:"err_msg,omitempty"`
19+
}
20+
21+
// TelemetryEmitter is the interface the supervisor calls to emit a usage
22+
// event. The daemon wires a real implementation; the no-op default is set
23+
// in newSupervisor so the supervisor never has to nil-check.
24+
type TelemetryEmitter interface {
25+
Emit(event TelemetryEvent)
26+
}
27+
28+
// noopEmitter discards every event. Used as the default when Deps does
29+
// not provide a Telemetry field, and also when consent is off.
30+
type noopEmitter struct{}
31+
32+
func (noopEmitter) Emit(TelemetryEvent) {}

0 commit comments

Comments
 (0)