-
Notifications
You must be signed in to change notification settings - Fork 687
chore(instrumentation): add persona analytics extension #6927
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| // This file is adapted from @vercel/detect-agent (v1.2.3) by Vercel, Inc. | ||
| // Source: https://github.com/vercel/vercel/blob/0d0b990edda112c5cc91e95e0d054878542fe3be/packages/detect-agent/src/index.ts | ||
| // | ||
| // Original work Copyright 2017 Vercel, Inc. | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // This Go port has been modified from the original TypeScript source. | ||
|
|
||
| package persona | ||
|
|
||
| import ( | ||
| "os" | ||
| "strings" | ||
| ) | ||
|
|
||
| // Agent is the canonical name of a known AI coding agent / harness. | ||
| type Agent string | ||
|
|
||
| const ( | ||
| AgentCursor Agent = "cursor" | ||
| AgentCursorCLI Agent = "cursor-cli" | ||
| AgentClaude Agent = "claude" | ||
| AgentCowork Agent = "cowork" | ||
| AgentDevin Agent = "devin" | ||
| AgentReplit Agent = "replit" | ||
| AgentGemini Agent = "gemini" | ||
| AgentCodex Agent = "codex" | ||
| AgentAntigravity Agent = "antigravity" | ||
| AgentAugmentCLI Agent = "augment-cli" | ||
| AgentOpenCode Agent = "opencode" | ||
| AgentGitHubCopilot Agent = "github-copilot" | ||
| AgentV0 Agent = "v0" | ||
| ) | ||
|
|
||
| // devinMarkerPath is a filesystem marker present inside the Devin sandbox. | ||
| const devinMarkerPath = "/opt/.devin" | ||
|
|
||
| // lookup abstracts the environment so detection can be tested without mutating | ||
| // the real process environment. | ||
| type lookup struct { | ||
| getenv func(string) string | ||
| fileExists func(string) bool | ||
| } | ||
|
|
||
| func osLookup() lookup { | ||
| return lookup{ | ||
| getenv: os.Getenv, | ||
| fileExists: func(path string) bool { | ||
| _, err := os.Stat(path) | ||
| return err == nil | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // signature describes how a single agent is recognised from the environment. | ||
| type signature struct { | ||
| agent Agent | ||
| match func(l lookup) bool | ||
| } | ||
|
|
||
| // anySet reports true if any of the given environment variables is set to a | ||
| // non-empty value. | ||
| func anySet(l lookup, keys ...string) bool { | ||
| for _, k := range keys { | ||
| if l.getenv(k) != "" { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // signatures is the ordered list of agent detectors. Order matters: the first | ||
| // match wins, so more specific signatures must precede more generic ones. | ||
| var signatures = []signature{ | ||
| {AgentCursor, func(l lookup) bool { return anySet(l, "CURSOR_TRACE_ID") }}, | ||
| {AgentCursorCLI, func(l lookup) bool { | ||
| return anySet(l, "CURSOR_AGENT") || l.getenv("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" | ||
| }}, | ||
| {AgentGemini, func(l lookup) bool { return anySet(l, "GEMINI_CLI") }}, | ||
| {AgentCodex, func(l lookup) bool { return anySet(l, "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID") }}, | ||
| {AgentAntigravity, func(l lookup) bool { return anySet(l, "ANTIGRAVITY_AGENT") }}, | ||
| {AgentAugmentCLI, func(l lookup) bool { return anySet(l, "AUGMENT_AGENT") }}, | ||
| {AgentOpenCode, func(l lookup) bool { return anySet(l, "OPENCODE_CLIENT") }}, | ||
| // Claude Code: the "cowork" surface is a more specific variant and must be | ||
| // checked first. | ||
| {AgentCowork, func(l lookup) bool { | ||
| return anySet(l, "CLAUDECODE", "CLAUDE_CODE") && anySet(l, "CLAUDE_CODE_IS_COWORK") | ||
| }}, | ||
| {AgentClaude, func(l lookup) bool { return anySet(l, "CLAUDECODE", "CLAUDE_CODE") }}, | ||
| {AgentReplit, func(l lookup) bool { return anySet(l, "REPL_ID") }}, | ||
| {AgentGitHubCopilot, func(l lookup) bool { | ||
| return anySet(l, "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN") | ||
| }}, | ||
| {AgentDevin, func(l lookup) bool { return l.fileExists(devinMarkerPath) }}, | ||
| } | ||
|
|
||
| // DetectAgent resolves the active AI agent, if any, from the current process | ||
| // environment. | ||
| func DetectAgent() (Agent, bool) { | ||
|
robertolopezlopez marked this conversation as resolved.
|
||
| return detectAgent(osLookup()) | ||
| } | ||
|
|
||
| // detectAgent resolves the active AI agent, if any. | ||
| // | ||
| // The AI_AGENT environment variable is the explicit, highest-priority signal: | ||
| // when set it is trusted verbatim (with a couple of canonicalisations). When it | ||
| // is absent, detection falls back to per-agent environment / filesystem | ||
| // signatures. | ||
| func detectAgent(l lookup) (Agent, bool) { | ||
| if name := strings.TrimSpace(l.getenv("AI_AGENT")); name != "" { | ||
| return canonicalAgent(name), true | ||
| } | ||
|
|
||
| for _, s := range signatures { | ||
|
robertolopezlopez marked this conversation as resolved.
|
||
| if s.match(l) { | ||
| return s.agent, true | ||
| } | ||
| } | ||
|
|
||
| return "", false | ||
| } | ||
|
|
||
| // canonicalAgent normalises an explicitly declared AI_AGENT value onto the set | ||
| // of canonical agent names. | ||
| func canonicalAgent(name string) Agent { | ||
| switch Agent(name) { | ||
| case "github-copilot-cli": | ||
| return AgentGitHubCopilot | ||
| default: | ||
| return Agent(name) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package persona | ||
|
|
||
| import "testing" | ||
|
|
||
| func newLookup(env map[string]string, files map[string]bool) lookup { | ||
| return lookup{ | ||
| getenv: func(k string) string { return env[k] }, | ||
| fileExists: func(p string) bool { return files[p] }, | ||
| } | ||
| } | ||
|
|
||
| func TestDetectAgent_Signatures(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| env map[string]string | ||
| files map[string]bool | ||
| want Agent | ||
| }{ | ||
| {name: "none", want: ""}, | ||
| {name: "explicit AI_AGENT", env: map[string]string{"AI_AGENT": " windsurf "}, want: "windsurf"}, | ||
| {name: "AI_AGENT overrides signatures", env: map[string]string{"AI_AGENT": "v0", "CLAUDECODE": "1"}, want: AgentV0}, | ||
| {name: "github-copilot-cli canonicalised", env: map[string]string{"AI_AGENT": "github-copilot-cli"}, want: AgentGitHubCopilot}, | ||
| {name: "cursor", env: map[string]string{"CURSOR_TRACE_ID": "x"}, want: AgentCursor}, | ||
| {name: "cursor-cli via CURSOR_AGENT", env: map[string]string{"CURSOR_AGENT": "1"}, want: AgentCursorCLI}, | ||
| {name: "cursor-cli via host role", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, want: AgentCursorCLI}, | ||
| {name: "cursor-cli host role mismatch", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "editor"}, want: ""}, | ||
| {name: "gemini", env: map[string]string{"GEMINI_CLI": "1"}, want: AgentGemini}, | ||
| {name: "codex", env: map[string]string{"CODEX_THREAD_ID": "abc"}, want: AgentCodex}, | ||
| {name: "antigravity", env: map[string]string{"ANTIGRAVITY_AGENT": "1"}, want: AgentAntigravity}, | ||
| {name: "augment", env: map[string]string{"AUGMENT_AGENT": "1"}, want: AgentAugmentCLI}, | ||
| {name: "opencode", env: map[string]string{"OPENCODE_CLIENT": "1"}, want: AgentOpenCode}, | ||
| {name: "claude", env: map[string]string{"CLAUDECODE": "1"}, want: AgentClaude}, | ||
| {name: "cowork", env: map[string]string{"CLAUDE_CODE": "1", "CLAUDE_CODE_IS_COWORK": "1"}, want: AgentCowork}, | ||
| {name: "replit", env: map[string]string{"REPL_ID": "1"}, want: AgentReplit}, | ||
| {name: "github copilot", env: map[string]string{"COPILOT_MODEL": "gpt"}, want: AgentGitHubCopilot}, | ||
| {name: "devin via marker file", files: map[string]bool{devinMarkerPath: true}, want: AgentDevin}, | ||
| {name: "empty AI_AGENT falls through", env: map[string]string{"AI_AGENT": " ", "GEMINI_CLI": "1"}, want: AgentGemini}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got, ok := detectAgent(newLookup(tt.env, tt.files)) | ||
| if (tt.want != "") != ok { | ||
| t.Fatalf("ok = %v, want %v (agent %q)", ok, tt.want != "", tt.want) | ||
| } | ||
| if got != tt.want { | ||
| t.Fatalf("agent = %q, want %q", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package persona | ||
|
|
||
| import ( | ||
| "os" | ||
|
|
||
| "github.com/mattn/go-isatty" | ||
| ) | ||
|
|
||
| // InteractiveMode is a bitmask recording which of the standard streams are | ||
| // attached to a terminal. Each stream is one bit, so a value can encode any | ||
| // combination. | ||
| type InteractiveMode uint8 | ||
|
|
||
| // Stream bits, each a distinct power of two so they can be OR'd together. | ||
| const ( | ||
| StdinTTY InteractiveMode = 1 << iota // stdin TTY flag (bit 0, value 1) | ||
| StdoutTTY // stdout TTY flag (bit 1, value 2) | ||
| StderrTTY // stderr TTY flag (bit 2, value 4) | ||
| ) | ||
|
Comment on lines
+15
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure if I follow what is happening here, how is it that those constants (even those not initialised) are attached to a terminal? - do you mind to explain? :-)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤣 i will change the comments
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks |
||
|
|
||
| // Has reports whether all bits in mask are set in m, so a combined mask | ||
| // (e.g. StdoutTTY|StderrTTY) requires every named stream to be a terminal. | ||
| func (m InteractiveMode) Has(mask InteractiveMode) bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you please add a good godoc explanation of what is going on here? ;-) |
||
| return m&mask == mask | ||
| } | ||
|
|
||
| // isTerminal reports whether the given file is attached to a terminal. It | ||
| // covers both native terminals and cygwin/msys2 terminals (e.g. git-bash on | ||
| // Windows), which are detected by a separate code path in go-isatty. | ||
| func isTerminal(f *os.File) bool { | ||
| return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) | ||
| } | ||
|
|
||
| // getInteractiveMode builds an InteractiveMode bitmask using check to test | ||
| // each standard stream. | ||
| func getInteractiveMode(check func(*os.File) bool) InteractiveMode { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: The PR description mentions this code is unchanged but the file seems different.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The persona.interactive key remains the same (true only if stdin is a TTY) here. The function simply generates a bitmask for all streams and records it in persona.interactive_mode. I'm happy to adjust this based on your preference. I could:
Please let me know what you think is best 😅
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please update PR description accordingly |
||
| var m InteractiveMode | ||
| if check(os.Stdin) { | ||
| m |= StdinTTY | ||
| } | ||
| if check(os.Stdout) { | ||
| m |= StdoutTTY | ||
| } | ||
| if check(os.Stderr) { | ||
| m |= StderrTTY | ||
| } | ||
| return m | ||
| } | ||
|
|
||
| // GetInteractiveMode probes the standard streams and returns a bitmask | ||
| // describing which of them are attached to a terminal. | ||
| func GetInteractiveMode() InteractiveMode { | ||
| return getInteractiveMode(isTerminal) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package persona | ||
|
|
||
| import ( | ||
| "os" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func Test_getInteractiveMode(t *testing.T) { | ||
| yes := func(*os.File) bool { return true } | ||
| no := func(*os.File) bool { return false } | ||
| only := func(target *os.File) func(*os.File) bool { | ||
| return func(f *os.File) bool { return f == target } | ||
| } | ||
|
|
||
| testCases := []struct { | ||
| name string | ||
| check func(*os.File) bool | ||
| want InteractiveMode | ||
| }{ | ||
| {"all TTY", yes, StdinTTY | StdoutTTY | StderrTTY}, | ||
| {"no TTY", no, 0}, | ||
| {"only stdin", only(os.Stdin), StdinTTY}, | ||
| {"only stdout", only(os.Stdout), StdoutTTY}, | ||
| {"only stderr", only(os.Stderr), StderrTTY}, | ||
| {"stdin and stdout", func(f *os.File) bool { return f == os.Stdin || f == os.Stdout }, StdinTTY | StdoutTTY}, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| assert.Equal(t, tc.want, getInteractiveMode(tc.check)) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func Test_isTerminal_pipeIsNotATerminal(t *testing.T) { | ||
| r, w, err := os.Pipe() | ||
| require.NoError(t, err) | ||
| defer r.Close() | ||
| defer w.Close() | ||
|
|
||
| assert.False(t, isTerminal(r), "read end of a pipe is not a terminal") | ||
| assert.False(t, isTerminal(w), "write end of a pipe is not a terminal") | ||
| } | ||
|
|
||
| func Test_isTerminal_regularFileIsNotATerminal(t *testing.T) { | ||
| f, err := os.CreateTemp(t.TempDir(), "interactive-test") | ||
| require.NoError(t, err) | ||
| defer f.Close() | ||
|
|
||
| assert.False(t, isTerminal(f), "a regular file is not a terminal") | ||
| } | ||
|
|
||
| func Test_GetInteractiveMode_underTestIsNonInteractive(t *testing.T) { | ||
| // The go test runner does not allocate a TTY for the standard streams, so | ||
| // no stream should be flagged as a terminal. | ||
| assert.Equal(t, InteractiveMode(0), GetInteractiveMode()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package persona | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: probably this file should be named analytics.go, or something different. I cannot see any persona type in the whole PR. Not blocker |
||
|
|
||
| const ( | ||
| // keyInteractive reports whether stdin is attached to a terminal. | ||
| keyInteractive = "persona.interactive" | ||
| // keyInteractiveMode is the bitmask of which standard streams are attached to | ||
| // a terminal (StdinTTY | StdoutTTY | StderrTTY). | ||
| keyInteractiveMode = "persona.interactive_mode" | ||
| // keyAgent reports the canonical name of the AI coding agent driving the | ||
| // invocation, when one is detected. | ||
| keyAgent = "persona.agent" | ||
| ) | ||
|
|
||
| // Analytics is the subset of the analytics sink that persona reporting needs. | ||
| type Analytics interface { | ||
| AddExtensionBoolValue(key string, value bool) | ||
| AddExtensionIntegerValue(key string, value int) | ||
| AddExtensionStringValue(key string, value string) | ||
| IsCiEnvironment() bool | ||
| } | ||
|
|
||
| // Report adds the persona extension values to the given analytics sink. | ||
| func Report(a Analytics) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would prefer to use a receiver here and in report() - you are even modifying the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That would be nice, but main.go will probably end up looking the same
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My point is not how it would look in main, but rather if we are modifying the analytics isntance state |
||
| report(a, GetInteractiveMode(), DetectAgent) | ||
| } | ||
|
|
||
| func report(a Analytics, mode InteractiveMode, detect func() (Agent, bool)) { | ||
| // A terminal on stdin is our signal that a human is driving the session. | ||
| interactive := mode.Has(StdinTTY) | ||
| // CI runners can't be trusted to report stdin honestly: some allocate a | ||
| // terminal or otherwise manipulate the stream with no human present (e.g. | ||
| // CircleCI injects null bytes into every command's stdin, see | ||
| // https://github.com/CircleCI-Public/circleci-cli/issues/456). Treat any CI | ||
| // invocation as non-interactive regardless of the TTY signal. | ||
| if a.IsCiEnvironment() { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please add a comment to document it in code |
||
| interactive = false | ||
| } | ||
| a.AddExtensionBoolValue(keyInteractive, interactive) | ||
| a.AddExtensionIntegerValue(keyInteractiveMode, int(mode)) | ||
| if agent, ok := detect(); ok { | ||
| a.AddExtensionStringValue(keyAgent, string(agent)) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are agent* and interactive* in the package persona? Without looking down below, I have the feeling that agent, interactive and persona have their own (complex?) internal logic.
Wouldn't it be cleaner to have them in separate packages and offer just the necessary resources, to isolate such domain specific logic? (i.e. avoiding tight coupling between them)