Broaden CLI socket telemetry and add restart listener command
This commit is contained in:
parent
93c167ff02
commit
db86d3e301
6 changed files with 296 additions and 19 deletions
|
|
@ -11,8 +11,8 @@ struct CLIError: Error, CustomStringConvertible {
|
|||
var description: String { message }
|
||||
}
|
||||
|
||||
private final class ClaudeHookSentryTelemetry {
|
||||
private let enabled: Bool
|
||||
private final class CLISocketSentryTelemetry {
|
||||
private let command: String
|
||||
private let subcommand: String
|
||||
private let socketPath: String
|
||||
private let envSocketPath: String?
|
||||
|
|
@ -27,13 +27,15 @@ private final class ClaudeHookSentryTelemetry {
|
|||
#endif
|
||||
|
||||
init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) {
|
||||
self.enabled = command == "claude-hook"
|
||||
self.command = command.lowercased()
|
||||
self.subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
self.socketPath = socketPath
|
||||
self.envSocketPath = processEnv["CMUX_SOCKET_PATH"]
|
||||
self.workspaceId = processEnv["CMUX_WORKSPACE_ID"]
|
||||
self.surfaceId = processEnv["CMUX_SURFACE_ID"]
|
||||
self.disabledByEnv = processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
|
||||
self.disabledByEnv =
|
||||
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
|
||||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
|
||||
}
|
||||
|
||||
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
|
||||
|
|
@ -44,7 +46,7 @@ private final class ClaudeHookSentryTelemetry {
|
|||
for (key, value) in data {
|
||||
payload[key] = value
|
||||
}
|
||||
let crumb = Breadcrumb(level: .info, category: "claude-hook.cli")
|
||||
let crumb = Breadcrumb(level: .info, category: "cmux.cli")
|
||||
crumb.message = message
|
||||
crumb.data = payload
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
|
|
@ -62,22 +64,25 @@ private final class ClaudeHookSentryTelemetry {
|
|||
context[key] = value
|
||||
}
|
||||
let subcommand = self.subcommand
|
||||
let command = self.command
|
||||
_ = SentrySDK.capture(error: error) { scope in
|
||||
scope.setLevel(.error)
|
||||
scope.setTag(value: "cmux-cli", key: "component")
|
||||
scope.setTag(value: subcommand, key: "claude_hook_subcommand")
|
||||
scope.setContext(value: context, key: "claude_hook")
|
||||
scope.setTag(value: command, key: "cli_command")
|
||||
scope.setTag(value: subcommand, key: "cli_subcommand")
|
||||
scope.setContext(value: context, key: "cli_socket")
|
||||
}
|
||||
SentrySDK.flush(timeout: 2.0)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var shouldEmit: Bool {
|
||||
enabled && !disabledByEnv
|
||||
!disabledByEnv
|
||||
}
|
||||
|
||||
private func baseContext() -> [String: Any] {
|
||||
var context: [String: Any] = [
|
||||
"command": command,
|
||||
"subcommand": subcommand,
|
||||
"requested_socket_path": socketPath,
|
||||
"env_socket_path": envSocketPath ?? "<unset>"
|
||||
|
|
@ -677,7 +682,7 @@ struct CMUXCLI {
|
|||
|
||||
let command = args[index]
|
||||
let commandArgs = Array(args[(index + 1)...])
|
||||
let hookTelemetry = ClaudeHookSentryTelemetry(
|
||||
let cliTelemetry = CLISocketSentryTelemetry(
|
||||
command: command,
|
||||
commandArgs: commandArgs,
|
||||
socketPath: socketPath,
|
||||
|
|
@ -698,16 +703,16 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
let client = SocketClient(path: socketPath)
|
||||
hookTelemetry.breadcrumb(
|
||||
cliTelemetry.breadcrumb(
|
||||
"socket.connect.attempt",
|
||||
data: ["command": command]
|
||||
)
|
||||
do {
|
||||
try client.connect()
|
||||
hookTelemetry.breadcrumb("socket.connect.success")
|
||||
cliTelemetry.breadcrumb("socket.connect.success")
|
||||
} catch {
|
||||
hookTelemetry.breadcrumb("socket.connect.failure")
|
||||
hookTelemetry.captureError(stage: "socket_connect", error: error)
|
||||
cliTelemetry.breadcrumb("socket.connect.failure")
|
||||
cliTelemetry.captureError(stage: "socket_connect", error: error)
|
||||
throw error
|
||||
}
|
||||
defer { client.close() }
|
||||
|
|
@ -1294,13 +1299,13 @@ struct CMUXCLI {
|
|||
print(response)
|
||||
|
||||
case "claude-hook":
|
||||
hookTelemetry.breadcrumb("claude-hook.dispatch")
|
||||
cliTelemetry.breadcrumb("claude-hook.dispatch")
|
||||
do {
|
||||
try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: hookTelemetry)
|
||||
hookTelemetry.breadcrumb("claude-hook.completed")
|
||||
try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
|
||||
cliTelemetry.breadcrumb("claude-hook.completed")
|
||||
} catch {
|
||||
hookTelemetry.breadcrumb("claude-hook.failure")
|
||||
hookTelemetry.captureError(stage: "claude_hook_dispatch", error: error)
|
||||
cliTelemetry.breadcrumb("claude-hook.failure")
|
||||
cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error)
|
||||
throw error
|
||||
}
|
||||
|
||||
|
|
@ -4534,7 +4539,7 @@ struct CMUXCLI {
|
|||
private func runClaudeHook(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
telemetry: ClaudeHookSentryTelemetry
|
||||
telemetry: CLISocketSentryTelemetry
|
||||
) throws {
|
||||
let subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
let hookArgs = Array(commandArgs.dropFirst())
|
||||
|
|
@ -5319,6 +5324,8 @@ struct CMUXCLI {
|
|||
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
|
||||
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
|
||||
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
|
||||
CMUX_CLI_SENTRY_DISABLED
|
||||
Set to 1 to disable CLI Sentry socket diagnostics.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3229,6 +3229,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
updateController.attemptUpdate()
|
||||
}
|
||||
|
||||
@objc func restartSocketListener(_ sender: Any?) {
|
||||
guard let tabManager else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
|
||||
let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey)
|
||||
?? SocketControlSettings.defaultMode.rawValue
|
||||
let userMode = SocketControlSettings.migrateMode(raw)
|
||||
let mode = SocketControlSettings.effectiveMode(userMode: userMode)
|
||||
guard mode != .off else {
|
||||
TerminalController.shared.stop()
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
|
||||
let socketPath = SocketControlSettings.socketPath()
|
||||
sentryBreadcrumb("socket.listener.restart", category: "socket", data: [
|
||||
"mode": mode.rawValue,
|
||||
"path": socketPath
|
||||
])
|
||||
TerminalController.shared.stop()
|
||||
TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)
|
||||
}
|
||||
|
||||
private func setupMenuBarExtra() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
menuBarExtraController = MenuBarExtraController(
|
||||
|
|
|
|||
|
|
@ -3530,6 +3530,14 @@ struct ContentView: View {
|
|||
keywords: ["attempt", "check", "update", "upgrade", "release"]
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.restartSocketListener",
|
||||
title: constant("Restart CLI Listener"),
|
||||
subtitle: constant("Global"),
|
||||
keywords: ["restart", "socket", "listener", "cli", "cmux", "control"]
|
||||
)
|
||||
)
|
||||
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
|
|
@ -3934,6 +3942,9 @@ struct ContentView: View {
|
|||
registry.register(commandId: "palette.attemptUpdate") {
|
||||
AppDelegate.shared?.attemptUpdate(nil)
|
||||
}
|
||||
registry.register(commandId: "palette.restartSocketListener") {
|
||||
AppDelegate.shared?.restartSocketListener(nil)
|
||||
}
|
||||
|
||||
registry.register(commandId: "palette.renameWorkspace") {
|
||||
beginRenameWorkspaceFlow()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ def main() -> int:
|
|||
pass
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CMUX_CLI_SENTRY_DISABLED"] = "1"
|
||||
env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
|
||||
env.pop("CMUX_SOCKET_PATH", None)
|
||||
|
||||
|
|
|
|||
115
tests/test_cli_socket_sentry_scope.py
Normal file
115
tests/test_cli_socket_sentry_scope.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI socket Sentry telemetry must apply to all commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
"private final class CLISocketSentryTelemetry {",
|
||||
"Missing CLISocketSentryTelemetry definition",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||',
|
||||
"Missing CMUX_CLI_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"',
|
||||
"Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"private var shouldEmit: Bool {\n !disabledByEnv\n }",
|
||||
"Telemetry scope should be command-agnostic (only disabled by env kill switch)",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let crumb = Breadcrumb(level: .info, category: "cmux.cli")',
|
||||
"Telemetry breadcrumb category should be cmux.cli",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'"command": command,',
|
||||
"Base telemetry context must include command name",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"let cliTelemetry = CLISocketSentryTelemetry(",
|
||||
"CLI should initialize generic socket telemetry",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'cliTelemetry.breadcrumb(\n "socket.connect.attempt",',
|
||||
"CLI should emit socket.connect.attempt breadcrumb for commands",
|
||||
failures,
|
||||
)
|
||||
|
||||
reject(
|
||||
content,
|
||||
"self.enabled = command == \"claude-hook\"",
|
||||
"Telemetry regressed to claude-hook-only scope",
|
||||
failures,
|
||||
)
|
||||
reject(
|
||||
content,
|
||||
"enabled && !disabledByEnv",
|
||||
"Telemetry still depends on legacy enabled flag",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI socket telemetry scope regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI socket telemetry scope is command-agnostic")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
118
tests/test_command_palette_socket_restart_command.py
Normal file
118
tests/test_command_palette_socket_restart_command.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette socket-listener restart command wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content_view,
|
||||
'commandId: "palette.restartSocketListener"',
|
||||
"Missing `palette.restartSocketListener` command contribution",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'title: constant("Restart CLI Listener")',
|
||||
"Missing `Restart CLI Listener` command title",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'registry.register(commandId: "palette.restartSocketListener") {',
|
||||
"Missing command handler registration for `palette.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
"AppDelegate.shared?.restartSocketListener(nil)",
|
||||
"Socket restart command handler does not call `AppDelegate.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
app_delegate,
|
||||
"@objc func restartSocketListener(_ sender: Any?) {",
|
||||
"Missing `AppDelegate.restartSocketListener` action",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"let mode = SocketControlSettings.effectiveMode(userMode: userMode)",
|
||||
"`restartSocketListener` no longer uses effective socket control mode",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"let socketPath = SocketControlSettings.socketPath()",
|
||||
"`restartSocketListener` no longer uses configured socket path",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.stop()",
|
||||
"`restartSocketListener` no longer stops current listener before restart",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)",
|
||||
"`restartSocketListener` no longer starts listener with current settings",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette socket restart command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette socket restart command wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue