Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions cmd/daemon/appstore_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,60 @@ package main

import (
"context"
"encoding/json"
"time"

"github.com/pilot-protocol/app-store/plugin/appstore"
"github.com/pilot-protocol/common/coreapi"
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

type appstoreAdapter struct {
svc *appstore.Service
svc *appstore.Service
telemetryURL string
identityPath string
}

// telemetryEmitter wraps the consent-gated telemetry client to satisfy
// the appstore.TelemetryEmitter interface. Events are sent as
// "app_usage" kind with the supervisor-provided fields as payload.
// Best-effort: send errors are logged but never block the caller.
type telemetryEmitter struct {
client *telemetry.Client
}

func (e *telemetryEmitter) Emit(ev appstore.TelemetryEvent) {
if e == nil || e.client == nil {
return
}
payload, err := json.Marshal(ev)
if err != nil {
return
}
_ = e.client.Send(telemetry.Event{
Kind: "app_usage",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
}

func (a *appstoreAdapter) Name() string { return a.svc.Name() }
func (a *appstoreAdapter) Order() int { return a.svc.Order() }
func (a *appstoreAdapter) Start(ctx context.Context, deps coreapi.Deps) error {
// Build a consent-gated telemetry client for app-usage events.
// When the URL is empty or identity is absent the client is a
// permanent no-op — the emitter never sends anything.
client := telemetry.NewClientFromIdentity(a.telemetryURL, a.identityPath, 0)
emitter := &telemetryEmitter{client: client}

return a.svc.Start(ctx, appstore.Deps{
Streams: deps.Streams,
Identity: deps.Identity,
Resolver: deps.Resolver,
Events: deps.Events,
Logger: deps.Logger,
Trust: deps.Trust,
Streams: deps.Streams,
Identity: deps.Identity,
Resolver: deps.Resolver,
Events: deps.Events,
Logger: deps.Logger,
Trust: deps.Trust,
Telemetry: emitter,
})
}
func (a *appstoreAdapter) Stop(ctx context.Context) error { return a.svc.Stop(ctx) }
Expand Down
35 changes: 28 additions & 7 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/pilot-protocol/webhook"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

var version = "dev"
Expand Down Expand Up @@ -100,6 +101,9 @@ func main() {
logFormat := flag.String("log-format", "text", "log format (text, json)")
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
telemetryURL := flag.String("telemetry-url", os.Getenv("PILOT_TELEMETRY_URL"),
"telemetry endpoint URL (empty = consent off, hard no-op). "+
"Env: PILOT_TELEMETRY_URL. Default: "+telemetry.DefaultEndpoint+".")
flag.Parse()
if *adminToken == "" {
if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" {
Expand Down Expand Up @@ -209,6 +213,7 @@ func main() {
CompatTLSTrust: *tlsTrust,
MOTDFeedURL: *motdFeedURL,
MOTDInterval: *motdInterval,
TelemetryURL: *telemetryURL,
})

// L11 plugin lifecycle (T7.1): composition root owns the
Expand Down Expand Up @@ -302,13 +307,29 @@ func main() {
if home, herr := os.UserHomeDir(); herr == nil {
appstoreInstallRoot = filepath.Join(home, ".pilot", "apps")
}
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
InstallRoot: appstoreInstallRoot,
RescanInterval: 2 * time.Second,
// Real catalogue trust anchor (replaces the all-zeros
// placeholder default): the embedded ed25519 catalogue key.
CatalogPubkey: []byte(catalogtrust.PublicKey()),
})}); err != nil {
// The app-usage telemetry emitter shares the daemon's identity file
// and telemetry URL. When consent is off (empty URL) the client is
// a permanent no-op — no goroutines, no dials, no buffering.
idPath := *identityPath
if idPath == "" {
if home, herr := os.UserHomeDir(); herr == nil {
defaultID := filepath.Join(home, ".pilot", "identity.json")
if _, serr := os.Stat(defaultID); serr == nil {
idPath = defaultID
}
}
}
if err := rt.Register(&appstoreAdapter{
svc: appstore.NewService(appstore.Config{
InstallRoot: appstoreInstallRoot,
RescanInterval: 2 * time.Second,
// Real catalogue trust anchor (replaces the all-zeros
// placeholder default): the embedded ed25519 catalogue key.
CatalogPubkey: []byte(catalogtrust.PublicKey()),
}),
telemetryURL: *telemetryURL,
identityPath: idPath,
}); err != nil {
log.Fatalf("register appstore: %v", err)
}

Expand Down
32 changes: 32 additions & 0 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (
"github.com/pilot-protocol/app-store/pkg/ipc"
"github.com/pilot-protocol/app-store/pkg/manifest"
"github.com/pilot-protocol/common/crypto"

"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

// cryptoSHA256 is named so the sha256 import isn't ambiguous-looking.
Expand Down Expand Up @@ -1209,6 +1211,36 @@ func cmdAppStoreInstall(args []string) {
Reason: reason,
})

// Emit a telemetry event for the successful install (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort: a send failure is logged but not fatal — the install
// itself already succeeded on disk.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
sourceStr := "catalogue"
if source == installSourceLocal {
sourceStr = "local"
}
payload, _ := json.Marshal(map[string]string{
"app_id": m.ID,
"version": m.AppVersion,
"source": sourceStr,
})
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "app_installed",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
if err != nil {
slog.Warn("telemetry send failed, install still successful", "app", m.ID, "err", err)
}
}

report := installReport{
AppID: m.ID,
AppVersion: m.AppVersion,
Expand Down
22 changes: 22 additions & 0 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
Expand All @@ -51,6 +52,7 @@ import (
"time"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

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

func cmdAppStoreCatalogue(_ []string) {
// Emit a telemetry event for the catalogue page view (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort, non-blocking: a send failure is logged but doesn't
// prevent the catalogue from rendering.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "catalogue_viewed",
TS: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
slog.Warn("telemetry send failed, catalogue still rendered", "err", err)
}
}

c, err := loadCatalogue()
if err != nil {
fatalHint("io_error",
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.10

require (
github.com/coder/websocket v1.8.14
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72
github.com/pilot-protocol/beacon v0.2.6
github.com/pilot-protocol/common v0.4.9-0.20260615113553-d5cbbfb3e5b6
github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98
Expand All @@ -18,7 +18,7 @@ require (
github.com/pilot-protocol/trustedagents v0.2.3
github.com/pilot-protocol/updater v0.2.2-0.20260529065627-220ed5b8383f
github.com/pilot-protocol/webhook v0.2.0
golang.org/x/sys v0.45.0
golang.org/x/sys v0.46.0
)

require (
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924 h1:
github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924/go.mod h1:f0umeJxswDG8/CctHpSFMlr5GLtE2GlPKkijIQErZuc=
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264 h1:NL9rFdakbVQ0V7xfJbCk8RJZSaQ1AmvdhAJwFIouMsk=
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264/go.mod h1:zoCxHYoNdj0V44OkG3Yzcye0jnwZDVUcJgAvR5Z1kwc=
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 h1:vDiQ7ZheKIzlNqfviu5zeQzGVTMP63k1hC5HodEuyeQ=
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg=
github.com/pilot-protocol/beacon v0.2.5 h1:5+pkSPoA35r+u4Hfrph/ZfOltOyiy8lh1sCfK5XqXKs=
github.com/pilot-protocol/beacon v0.2.5/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4=
github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE=
Expand Down Expand Up @@ -56,3 +58,5 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
6 changes: 6 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ type Config struct {
// Feature flags — ablation testing. All default false (current behavior).
BeaconRTTProbe bool // probe beacon RTT; override hash pick when >2× slower than best

// Telemetry consent gate. When set to the telemetry endpoint URL,
// the daemon initialises a telemetry client that emits signed events
// (install, usage, view, review). When empty (default), the client
// is a hard no-op: no dial, no buffering, no goroutines.
TelemetryURL string

// Compat-mode transport. Default empty ("" or "udp") = today's
// behavior: bind a UDP socket via udpio.Listen. Set "compat" to
// dial WSS to BeaconURL instead (for daemons in UDP-blocked
Expand Down
Loading
Loading