Skip to content

Commit 83c84c8

Browse files
committed
feat(telemetry): emit install events (PILOT-401)
Add a NewClientFromIdentity convenience constructor to the telemetry package that creates a consent-gated client from a node's Ed25519 identity file on disk. Wire telemetry into the app-store install command: after a successful install (bundle validated, manifest planted, audit logs written), emit an app_installed event carrying app_id, version, and source (catalogue|local). The emission is best-effort and consent-gated — when PILOT_TELEMETRY_URL is empty or identity.json is absent, the client is a hard no-op. Tests cover the new NewClientFromIdentity helper with valid identity, missing identity, loose file permissions, and empty URL (all no-op paths). Closes PILOT-401
1 parent 8437fc1 commit 83c84c8

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

cmd/pilotctl/appstore.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import (
3535
"github.com/pilot-protocol/app-store/pkg/ipc"
3636
"github.com/pilot-protocol/app-store/pkg/manifest"
3737
"github.com/pilot-protocol/common/crypto"
38+
39+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
3840
)
3941

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

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

pkg/telemetry/client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"strings"
2020
"sync"
2121
"time"
22+
23+
"github.com/pilot-protocol/common/crypto"
2224
)
2325

2426
// DefaultEndpoint is the production telemetry ingestion URL.
@@ -148,6 +150,32 @@ func (c *Client) Send(events ...Event) error {
148150
return nil
149151
}
150152

153+
// NewClientFromIdentity creates a consent-gated telemetry client from an
154+
// Ed25519 identity file on disk and a telemetry URL. When url is empty
155+
// the client is a permanent no-op. Returns nil if the identity file does
156+
// not exist (first run).
157+
func NewClientFromIdentity(url, identityPath string, nodeID int64) *Client {
158+
c := New(url, nodeID)
159+
if c.disabled || url == "" {
160+
return c
161+
}
162+
163+
id, err := crypto.LoadIdentity(identityPath)
164+
if err != nil {
165+
slog.Warn("telemetry: can't load identity, staying disabled", "path", identityPath, "err", err)
166+
return c
167+
}
168+
if id == nil {
169+
slog.Debug("telemetry: no identity file yet, staying disabled", "path", identityPath)
170+
return c
171+
}
172+
173+
slog.Debug("telemetry: identity loaded, enabling client", "path", identityPath,
174+
"pubkey", crypto.EncodePublicKey(id.PublicKey))
175+
c.SetSigner(id.Sign, crypto.EncodePublicKey(id.PublicKey))
176+
return c
177+
}
178+
151179
// SignMessage implements the signing contract directly, without an HTTP
152180
// POST. Useful for tests and for components that want to sign arbitrary
153181
// byte payloads. Returns (timestamp, pubKeyB64, signatureB64, error).

pkg/telemetry/client_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import (
66
"crypto/ed25519"
77
"crypto/rand"
88
"encoding/base64"
9+
"os"
10+
"path/filepath"
911
"testing"
12+
13+
"github.com/pilot-protocol/common/crypto"
1014
)
1115

1216
func TestNewNoop(t *testing.T) {
@@ -48,6 +52,64 @@ func TestSendErrorsOnBadURL(t *testing.T) {
4852
}
4953
}
5054

55+
func TestNewClientFromIdentity(t *testing.T) {
56+
// Empty URL = no-op, no identity file needed
57+
c := NewClientFromIdentity("", "/nonexistent/identity.json", 0)
58+
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
59+
t.Fatal("no-op with no url should not error:", err)
60+
}
61+
62+
// Non-empty URL with no identity file = noop (file doesn't exist = first run)
63+
c2 := NewClientFromIdentity("http://example.com/v1/events", "/nonexistent/identity.json", 42)
64+
if err := c2.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
65+
t.Fatal("no identity file should be no-op, not error:", err)
66+
}
67+
68+
// Valid identity file
69+
dir := t.TempDir()
70+
idPath := filepath.Join(dir, "identity.json")
71+
id, err := crypto.GenerateIdentity()
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
if err := crypto.SaveIdentity(idPath, id); err != nil {
76+
t.Fatal(err)
77+
}
78+
79+
c3 := NewClientFromIdentity("http://example.com/v1/events", idPath, 7)
80+
if c3.disabled {
81+
t.Fatal("expected enabled for valid identity + URL")
82+
}
83+
if c3.sign == nil {
84+
t.Fatal("expected signer to be set")
85+
}
86+
if c3.pubKeyB == "" {
87+
t.Fatal("expected public key to be set")
88+
}
89+
}
90+
91+
func TestNewClientFromIdentityLoosePerms(t *testing.T) {
92+
dir := t.TempDir()
93+
idPath := filepath.Join(dir, "identity.json")
94+
id, err := crypto.GenerateIdentity()
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
if err := crypto.SaveIdentity(idPath, id); err != nil {
99+
t.Fatal(err)
100+
}
101+
// Make the identity file world-readable — simulates a manual restore
102+
if err := os.Chmod(idPath, 0644); err != nil {
103+
t.Fatal(err)
104+
}
105+
106+
// LoadIdentity refuses loose perms; client should warn and stay noop
107+
c := NewClientFromIdentity("http://example.com/v1/events", idPath, 7)
108+
if c.sign != nil {
109+
t.Fatal("expected no signer for loose-permissions identity file")
110+
}
111+
}
112+
51113
func TestSignMessageRoundTrip(t *testing.T) {
52114
pub, priv, err := ed25519.GenerateKey(rand.Reader)
53115
if err != nil {

0 commit comments

Comments
 (0)