diff --git a/.goreleaser.yml b/.goreleaser.yml index 677413407..807e30d5b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -30,6 +30,14 @@ builds: ldflags: - "{{ .Env.COMMON_LDFLAGS }}" - -X github.com/chainloop-dev/chainloop/app/cli/cmd.Version={{ .Version }} + # Telemetry destination as a release-time input. Both fall back to the values compiled + # into app/cli/cmd/root.go, which is what builds outside this pipeline (Homebrew, + # go install) use. `index .Env` rather than `.Env.X` because GoReleaser templates run + # with missingkey=error, and `with` rather than `envOrDefault` because GitHub Actions + # renders a missing secret as an empty string, which would inject an empty key and + # silently disable telemetry for the whole release. + - '{{ with index .Env "POSTHOG_API_KEY" }}-X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogAPIKey={{ . }}{{ end }}' + - '{{ with index .Env "POSTHOG_ENDPOINT" }}-X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogEndpoint={{ . }}{{ end }}' targets: - darwin_amd64 - darwin_arm64 diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 55e3ce8c2..1c81cda6f 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -25,7 +25,6 @@ import ( "path/filepath" "strings" "sync" - "time" "github.com/adrg/xdg" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" @@ -208,9 +207,9 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { go func() { defer telemetryWg.Done() - // Create a context that times out after 1 seconds, this is because posthog has a 10 seconds hardcoded timeout - // and we want to finish earlier than that - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + // Stop waiting on the delivery goroutine after the flush deadline, so a slow + // or unreachable telemetry endpoint cannot hold up the command. + ctx, cancel := context.WithTimeout(context.Background(), telemetry.FlushTimeout) defer cancel() done := make(chan struct{}) @@ -451,12 +450,18 @@ func loadAuthToken(cmd *cobra.Command) (string, bool, error) { } var ( - // Posthog API key and endpoint are not sensitive information it represents Chainloop's Posthog instance. - // It can be overridden by the user if they want to use their own instance of Posthog or deactivated by setting - // DO_NOT_TRACK=1 more information that can be found at: https://github.com/chainloop-dev/chainloop/blob/main/docs/docs/reference/operator/cli-telemetry.mdx + // Posthog API key and endpoint are not sensitive information, they represent Chainloop's + // Posthog instance. Both are overridable at build time with -X so anyone compiling the CLI + // can point it at their own instance, and telemetry can be turned off entirely with + // DO_NOT_TRACK=1. See https://docs.chainloop.dev/command-line-reference/cli-telemetry. + // + // The endpoint is compiled in and has no runtime override, so an installed binary reports + // wherever it was built to report. https://crb.chainloop.dev is therefore frozen on the + // previous project to keep serving binaries released before this change; it must not be + // repointed. // nolint:gosec - posthogAPIKey = "phc_TWWW19kEiD6sEejlHKWcICQ5Vc06vZUTYia8WdPB0A0" // gitleaks:allow - posthogEndpoint = "https://crb.chainloop.dev" + posthogAPIKey = "phc_rCvU7fL4Ndr4GpzH54RNdyNUWNgTpuHX7ApDxFW6G9FS" // gitleaks:allow + posthogEndpoint = "https://t.chainloop.dev" ) // recordCommand sends the command to the telemetry service diff --git a/app/cli/internal/telemetry/posthog/posthog.go b/app/cli/internal/telemetry/posthog/posthog.go index 39c3683c3..27f25a572 100644 --- a/app/cli/internal/telemetry/posthog/posthog.go +++ b/app/cli/internal/telemetry/posthog/posthog.go @@ -40,6 +40,8 @@ func NewClient(apiKey string, endpointURL string) (*Tracker, error) { client, err := posthog.NewWithConfig(apiKey, posthog.Config{ Endpoint: endpointURL, Logger: posthog.StdLogger(noopLogger, false), + // Close is what flushes the batch, and it waits indefinitely unless bounded. + ShutdownTimeout: telemetry.FlushTimeout, }) if err != nil { return nil, fmt.Errorf("failed to create PostHog client: %w", err) @@ -50,9 +52,17 @@ func NewClient(apiKey string, endpointURL string) (*Tracker, error) { }, nil } +// enqueuer is the subset of posthog.Client that the Tracker uses. Depending on the +// narrow interface instead of the full SDK one keeps the tracker testable: the SDK +// interface also carries the feature-flag surface, which this package never touches. +type enqueuer interface { + posthog.EnqueueClient + Close() error +} + // Tracker is an implementation of the telemetry.Client interface for PostHog. type Tracker struct { - client posthog.Client + client enqueuer } // TrackEvent sends an event to the PostHog server. @@ -73,24 +83,26 @@ func (p *Tracker) TrackEvent(_ context.Context, eventName string, id string, tag msg.Properties.Set(k, v) } - // Assign the installation ID if available as a group. - // It creates a new group named cp_installation where the values are the cp_url_hash. + // Both groups belong on the same event: cp_installation identifies the control plane the + // command talked to, organization the tenant within it. They are set on a single Groups + // map because replacing the map would drop whichever group was assigned first. + groups := posthog.NewGroups() if tags["cp_url_hash"] != "" { - msg.Groups = posthog. - NewGroups(). - Set("cp_installation", tags["cp_url_hash"]) + groups.Set("cp_installation", tags["cp_url_hash"]) } - // It creates a new group named org_id where the values are the org_id. if tags["org_id"] != "" { - msg.Groups = posthog. - NewGroups(). - Set("organization", tags["org_id"]) + groups.Set("organization", tags["org_id"]) } - // Assign an alias to the userID in the following cases: - // - The machine ID is available and different from the userID. - // - The userID is different from the default one. - // An alias can help to track the same user across different devices even when it was not logged in. - if (tags["machine_id"] != "" && tags["machine_id"] != id) && id != telemetry.UnrecognisedUserID { + if len(groups) > 0 { + msg.Groups = groups + } + + // Alias the machine ID onto the account so the events a user produced before logging in + // still resolve to them. Only interactive user sessions qualify: API and federated tokens + // are shared identities running on ephemeral CI machines, where aliasing merges unrelated + // runners into a single person and emits an alias on every command. + if tags.IsInteractiveUserSession() && + (tags["machine_id"] != "" && tags["machine_id"] != id) && id != telemetry.UnrecognisedUserID { if err := p.client.Enqueue(posthog.Alias{ DistinctId: id, Alias: tags["machine_id"], diff --git a/app/cli/internal/telemetry/posthog/tracker_internal_test.go b/app/cli/internal/telemetry/posthog/tracker_internal_test.go new file mode 100644 index 000000000..e8b476e7a --- /dev/null +++ b/app/cli/internal/telemetry/posthog/tracker_internal_test.go @@ -0,0 +1,192 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package posthog + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" + "github.com/posthog/posthog-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingClient captures the messages the Tracker enqueues, in order, so the tests +// can assert on the exact payload that would reach PostHog. +type recordingClient struct { + messages []posthog.Message + closed bool +} + +func (r *recordingClient) Enqueue(msg posthog.Message) error { + r.messages = append(r.messages, msg) + return nil +} + +func (r *recordingClient) Close() error { + r.closed = true + return nil +} + +const ( + testCPHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + testOrgID = "3b8f3b1e-0b7a-4a4a-9a1e-8a6b4c2d1e0f" + testUserID = "9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8" + testMachineID = "machine-1234" + + tagCPURLHash = "cp_url_hash" + tagMachineID = "machine_id" + tagTokenType = "token_type" + tagCI = "ci" + + groupCPInstallation = "cp_installation" + + tagFalse = "false" + tagTrue = "true" +) + +func TestTrackEventGroupsAndAlias(t *testing.T) { + testCases := []struct { + name string + // id is the distinct ID the CommandTracker resolved for the event. + id string + tags telemetry.Tags + wantGroups posthog.Groups + wantAliasFor string + wantNoGroups bool + wantAliasCount int + }{ + { + // Unauthenticated run: no token parsed, so the machine ID is the distinct ID + // and there is nothing to alias it to. + name: "unauthenticated, installation group only", + id: testMachineID, + tags: telemetry.Tags{ + tagCPURLHash: testCPHash, + tagMachineID: testMachineID, + tagCI: tagFalse, + }, + wantGroups: posthog.Groups{groupCPInstallation: testCPHash}, + }, + { + // An API token carries an org ID. Both groups have to travel on the same event: + // dropping cp_installation here is what made the group keys look inconsistent + // across CLI versions. + name: "api token in CI, both groups and no alias", + id: testOrgID, + tags: telemetry.Tags{ + tagCPURLHash: testCPHash, + "org_id": testOrgID, + tagMachineID: testMachineID, + tagTokenType: "AUTH_TYPE_API_TOKEN", + tagCI: tagTrue, + }, + wantGroups: posthog.Groups{ + groupCPInstallation: testCPHash, + "organization": testOrgID, + }, + }, + { + // The only case where stitching a pre-login machine identity onto an account + // is meaningful. + name: "interactive user token, aliased", + id: testUserID, + tags: telemetry.Tags{ + tagCPURLHash: testCPHash, + tagMachineID: testMachineID, + tagTokenType: "AUTH_TYPE_USER", + tagCI: tagFalse, + }, + wantGroups: posthog.Groups{groupCPInstallation: testCPHash}, + wantAliasFor: testMachineID, + wantAliasCount: 1, + }, + { + // A user token used from CI is still a shared identity across ephemeral machines. + name: "user token in CI, not aliased", + id: testUserID, + tags: telemetry.Tags{ + tagCPURLHash: testCPHash, + tagMachineID: testMachineID, + tagTokenType: "AUTH_TYPE_USER", + tagCI: tagTrue, + }, + wantGroups: posthog.Groups{groupCPInstallation: testCPHash}, + }, + { + // Federated tokens resolve to the issuer URL, which is identical for every + // customer, so aliasing merges unrelated runners into a single person. + name: "federated token, not aliased", + id: "https://token.actions.githubusercontent.com", + tags: telemetry.Tags{ + tagCPURLHash: testCPHash, + tagMachineID: testMachineID, + tagTokenType: "AUTH_TYPE_FEDERATED", + tagCI: tagTrue, + }, + wantGroups: posthog.Groups{groupCPInstallation: testCPHash}, + }, + { + name: "no group tags, no groups sent", + id: testMachineID, + tags: telemetry.Tags{ + tagMachineID: testMachineID, + tagCI: tagFalse, + }, + wantNoGroups: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := &recordingClient{} + tracker := &Tracker{client: client} + + err := tracker.TrackEvent(context.Background(), "command_executed", tc.id, tc.tags) + require.NoError(t, err) + assert.True(t, client.closed, "the client must be closed so the batch is flushed") + + var captures []posthog.Capture + var aliases []posthog.Alias + for _, msg := range client.messages { + switch m := msg.(type) { + case posthog.Capture: + captures = append(captures, m) + case posthog.Alias: + aliases = append(aliases, m) + default: + require.Failf(t, "unexpected message type", "%T", msg) + } + } + + require.Len(t, captures, 1) + if tc.wantNoGroups { + assert.Nil(t, captures[0].Groups) + } else { + assert.Equal(t, tc.wantGroups, captures[0].Groups) + } + + require.Len(t, aliases, tc.wantAliasCount) + if tc.wantAliasCount > 0 { + assert.Equal(t, tc.id, aliases[0].DistinctId) + assert.Equal(t, tc.wantAliasFor, aliases[0].Alias) + // The alias has to be enqueued before the event it stitches. + assert.IsType(t, posthog.Alias{}, client.messages[0]) + } + }) + } +} diff --git a/app/cli/internal/telemetry/telemetry.go b/app/cli/internal/telemetry/telemetry.go index b1f184864..d0759c4fe 100644 --- a/app/cli/internal/telemetry/telemetry.go +++ b/app/cli/internal/telemetry/telemetry.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package telemetry import ( "context" "runtime" + "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" @@ -28,6 +29,17 @@ import ( const commandTrackerEventName = "command_executed" const UnrecognisedUserID = "unrecognised" +// FlushTimeout bounds how long the CLI spends delivering a telemetry event. The PostHog +// client takes it as the deadline for flushing its batch on close, and the command hook +// takes it as the deadline after which it stops waiting on the delivery goroutine. Both +// read it from here so the two deadlines cannot drift apart. +const FlushTimeout = 2 * time.Second + +// authTypeUser mirrors v1.Attestation_Auth_AUTH_TYPE_USER.String(). It is duplicated as a +// literal so this package keeps no dependency on the attestation API; a test pins the two +// values together. +const authTypeUser = "AUTH_TYPE_USER" + // Tags represents a collection of event tags. type Tags map[string]string @@ -68,6 +80,15 @@ func (t *CommandTracker) Track(ctx context.Context, cmd string, tags Tags) error return t.client.TrackEvent(ctx, commandTrackerEventName, id, computedTags) } +// IsInteractiveUserSession reports whether the command was run by a logged-in human outside +// of CI. API and federated tokens are shared identities: an API token belongs to an +// organization rather than a person, and a federated token resolves to the issuer URL, which +// is the same value for every Chainloop installation using that provider. Anything derived +// from the machine the command ran on is only meaningful for the interactive case. +func (tg Tags) IsInteractiveUserSession() bool { + return tg["token_type"] == authTypeUser && tg["ci"] == "false" +} + // Merges two tag maps. func mergeTags(defaultTags, userTags Tags) Tags { result := make(Tags) diff --git a/app/cli/internal/telemetry/telemetry_test.go b/app/cli/internal/telemetry/telemetry_test.go index 1fb9f244e..d01300eca 100644 --- a/app/cli/internal/telemetry/telemetry_test.go +++ b/app/cli/internal/telemetry/telemetry_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,10 +23,54 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/mocks" + v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +const ( + tagCI = "ci" + tagTokenType = "token_type" + tagFalse = "false" + tagTrue = "true" +) + +func TestTagsIsInteractiveUserSession(t *testing.T) { + testCases := []struct { + name string + tags telemetry.Tags + want bool + }{ + { + name: "user token outside CI", + tags: telemetry.Tags{tagTokenType: v1.Attestation_Auth_AUTH_TYPE_USER.String(), tagCI: tagFalse}, + want: true, + }, + { + name: "user token in CI", + tags: telemetry.Tags{tagTokenType: v1.Attestation_Auth_AUTH_TYPE_USER.String(), tagCI: tagTrue}, + }, + { + name: "api token outside CI", + tags: telemetry.Tags{tagTokenType: v1.Attestation_Auth_AUTH_TYPE_API_TOKEN.String(), tagCI: tagFalse}, + }, + { + name: "federated token in CI", + tags: telemetry.Tags{tagTokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED.String(), tagCI: tagTrue}, + }, + { + name: "unauthenticated", + tags: telemetry.Tags{tagCI: tagFalse}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.tags.IsInteractiveUserSession()) + }) + } +} + func TestTagsWithEnvironmentInfo(t *testing.T) { tags := telemetry.Tags{}.WithRuntimeInfo() diff --git a/devel/sandbox-kit/spec.yaml b/devel/sandbox-kit/spec.yaml index 31b3ea842..25ef8caf3 100644 --- a/devel/sandbox-kit/spec.yaml +++ b/devel/sandbox-kit/spec.yaml @@ -364,7 +364,14 @@ permissions: # v1: `network.allowedDomains:` - "api.cp.chainloop.dev:443" # control plane - ALWAYS (attest + keyless signing CSR) - "api.cas.chainloop.dev:443" # CAS - only if the org CAS backend is external - "api.app.chainloop.dev:443" # platform backend - the EE CLI also dials this - - "crb.chainloop.dev:443" # CLI usage telemetry (PostHog) + - "t.chainloop.dev:443" # CLI usage telemetry (PostHog) - current builds + - "crb.chainloop.dev:443" # CLI usage telemetry (PostHog) - binaries released + # before the endpoint moved. The host is compiled + # in, so an older tag checked out in a sandbox + # still reaches for it. Commands succeed either + # way, but a connection that is dropped rather + # than refused costs each tracked command a + # couple of seconds waiting on the send deadline. - "timestamp.digicert.com:80" # RFC 3161 timestamp authority (plain HTTP, signed response) # used during attestation signing; host comes from the # control plane's signing options