Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"path/filepath"
"strings"
"sync"
"time"

"github.com/adrg/xdg"
"github.com/chainloop-dev/chainloop/app/cli/internal/telemetry"
Expand Down Expand Up @@ -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{})

Expand Down Expand Up @@ -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
Expand Down
42 changes: 27 additions & 15 deletions app/cli/internal/telemetry/posthog/posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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"],
Expand Down
192 changes: 192 additions & 0 deletions app/cli/internal/telemetry/posthog/tracker_internal_test.go
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
javirln marked this conversation as resolved.
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])
}
})
}
}
23 changes: 22 additions & 1 deletion app/cli/internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading