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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.7.1] - 2026-07-31

### Added

- **Organization usage metrics.** OrbCode now reports metadata-only user-message events, model/version-aware accepted agent line counts, and newly observed Git commit line totals for AI-share, active-user, leaderboard, conversation, and client-version analytics. Git remotes are credential-sanitized, and prompt/file contents are never included.

## [6.7.0] - 2026-07-29

### Added
Expand Down Expand Up @@ -856,7 +862,8 @@ non-interactive mode.
- Cross-platform shell detection and path handling in
`execute_command` (Windows vs POSIX, `cmd` vs `bash`, etc.).

[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.0...HEAD
[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.1...HEAD
[6.7.1]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.0...v6.7.1
[6.7.0]: https://github.com/MatterAIOrg/OrbCode/compare/v6.6.8...v6.7.0
[6.6.7]: https://github.com/MatterAIOrg/OrbCode/compare/v0.6.0...v6.6.7
[0.6.0]: https://github.com/MatterAIOrg/OrbCode/compare/v0.5.10...v0.6.0
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@matterailab/orbcode",
"version": "6.7.0",
"version": "6.7.1",
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
"type": "module",
"bin": {
Expand Down Expand Up @@ -36,6 +36,7 @@
"test:attachments": "node --import tsx --test test/attachments.test.ts",
"test:mcp": "node --import tsx --test test/mcp-client.test.ts",
"test:plugins": "node --import tsx --test test/plugins.test.ts",
"test:metrics": "node --import tsx --test test/metrics.test.ts",
"test:search": "node --import tsx --test test/search-files.test.ts",
"test:ui": "bun test test/ui-viewport.test.tsx",
"prepublishOnly": "npm run typecheck && npm run build"
Expand Down
245 changes: 241 additions & 4 deletions src/api/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { execFileSync } from "node:child_process"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import * as path from "node:path"

import { getUrlFromToken } from "../auth/auth.js"
import { X_AXON_REPO } from "./headers.js"
import { VERSION } from "../branding.js"
import { getConfigDir } from "../config/settings.js"
import {
DEFAULT_HEADERS,
getClientMetadataHeaders,
X_AXON_REPO,
} from "./headers.js"

/** File-extension → language code mapping (ported from the extension). */
const LANGUAGE_MAP: Record<string, string> = {
Expand Down Expand Up @@ -56,6 +65,222 @@ export interface ReportLineMetricsOptions {
linesAdded: number
linesDeleted: number
linesUpdated?: number
model?: string
}

export interface UsageEvent {
eventId?: string
eventType: "user_message" | "committed_code" | "client_heartbeat"
taskId?: string
model?: string
repo: string
linesAdded?: number
linesModified?: number
linesDeleted?: number
commitHash?: string
timestamp?: string
}

export interface GitCommitMetrics {
hash: string
linesAdded: number
linesDeleted: number
timestamp: string
authorEmail: string
}

export interface ObservedGitCommits {
head?: string
commits: GitCommitMetrics[]
}

/** Remove credentials and normalize common SSH remotes before telemetry. */
export function normalizeGitRemote(remote: string): string {
const value = remote.trim()
const scpMatch = value.match(/^(?:[^@]+@)?([^:]+):(.+)$/)
if (scpMatch && !value.includes("://")) {
return `https://${scpMatch[1]}/${scpMatch[2]}`
}

try {
const url = new URL(value)
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return url.toString().replace(/\/$/, "")
} catch {
return ""
}
}

export function detectGitRepo(cwd: string): string {
try {
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
const normalized = normalizeGitRemote(remote)
if (normalized) return normalized
} catch {
// not a git repo or no remote
}
return path.basename(cwd)
}

export function getGitHead(cwd: string): string | undefined {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
} catch {
return undefined
}
}

const usageHeadsPath = (): string => path.join(getConfigDir(), "usage-git-heads.json")

const usageRepoKey = (cwd: string): string => `${detectGitRepo(cwd)}|${path.resolve(cwd)}`

export function getPersistedGitHead(cwd: string): string | undefined {
try {
const file = usageHeadsPath()
if (!existsSync(file)) return undefined
const heads = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>
return heads[usageRepoKey(cwd)]
} catch {
return undefined
}
}

export function persistGitHead(cwd: string, head: string | undefined): void {
if (!head) return
try {
const file = usageHeadsPath()
mkdirSync(path.dirname(file), { recursive: true })
let heads: Record<string, string> = {}
if (existsSync(file)) {
try {
heads = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>
} catch {
// Replace an unreadable tracker file with a fresh map.
}
}
heads[usageRepoKey(cwd)] = head
writeFileSync(file, JSON.stringify(heads), { mode: 0o600 })
} catch {
// Local baselines are best-effort, like the network event itself.
}
}

/**
* Return commits created since the last observed HEAD. Branch switches and
* rewritten/non-descendant histories become a new baseline instead of being
* misreported as newly authored code.
*/
export function observeGitCommits(cwd: string, previousHead?: string): ObservedGitCommits {
const head = getGitHead(cwd)
if (!head || !previousHead || head === previousHead) return { head, commits: [] }

try {
execFileSync("git", ["merge-base", "--is-ancestor", previousHead, head], {
cwd,
stdio: "ignore",
})
} catch {
return { head, commits: [] }
}

try {
let configuredAuthorEmail = ""
try {
configuredAuthorEmail = execFileSync("git", ["config", "--get", "user.email"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
.toLowerCase()
} catch {
// A repository can still have commits when identity comes from env vars.
}
const hashes = execFileSync("git", ["rev-list", "--reverse", `${previousHead}..${head}`], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
.split("\n")
.filter(Boolean)

const commits = hashes.map((hash) => {
const authorEmail = execFileSync("git", ["show", "-s", "--format=%ae", hash], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
const numstat = execFileSync("git", ["show", "--numstat", "--format=", "--no-renames", hash], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
}).toString()
let linesAdded = 0
let linesDeleted = 0
for (const line of numstat.split("\n")) {
const [added, deleted] = line.split("\t")
if (/^\d+$/.test(added)) linesAdded += Number(added)
if (/^\d+$/.test(deleted)) linesDeleted += Number(deleted)
}
const timestamp = execFileSync("git", ["show", "-s", "--format=%cI", hash], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim()
return { hash, linesAdded, linesDeleted, timestamp, authorEmail }
}).filter(
(commit) =>
!configuredAuthorEmail || commit.authorEmail.toLowerCase() === configuredAuthorEmail,
)

return { head, commits }
} catch {
return { head, commits: [] }
}
}

/** Report a non-content usage event. User prompt text is never transmitted. */
export async function reportUsageEvent(token: string, event: UsageEvent): Promise<void> {
if (!token) return
const url = getUrlFromToken("https://api.matterai.so/axoncode/usage/events", token)

try {
await fetch(url, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
...getClientMetadataHeaders(0),
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
[X_AXON_REPO]: event.repo,
},
body: JSON.stringify({
...event,
eventId: event.eventId || randomUUID(),
client: "orbcode",
clientVersion: VERSION,
ideName: "CLI",
}),
signal: AbortSignal.timeout(10000),
})
} catch {
// network/timeout errors: best-effort, swallow
}
}

/**
Expand All @@ -65,7 +290,7 @@ export interface ReportLineMetricsOptions {
* only after the edit has been written to disk.
*/
export async function reportLineMetrics(options: ReportLineMetricsOptions): Promise<void> {
const { taskId, token, repo, language, linesAdded, linesDeleted } = options
const { taskId, token, repo, language, linesAdded, linesDeleted, model } = options
const linesUpdated = options.linesUpdated ?? 0
if (!token) return
if (linesAdded === 0 && linesDeleted === 0 && linesUpdated === 0) return
Expand All @@ -79,14 +304,26 @@ export async function reportLineMetrics(options: ReportLineMetricsOptions): Prom
await fetch(url, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
...getClientMetadataHeaders(0),
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
[X_AXON_REPO]: repo,
},
body: JSON.stringify({ language, linesAdded, linesUpdated, linesDeleted }),
body: JSON.stringify({
eventId: randomUUID(),
language,
linesAdded,
linesUpdated,
linesDeleted,
model,
client: "orbcode",
clientVersion: VERSION,
ideName: "CLI",
}),
signal: AbortSignal.timeout(10000),
})
} catch {
// network/timeout errors: best-effort, swallow
}
}
}
Loading
Loading