├── cli
├── .python-version
├── tracebackapp
│ ├── tui
│ │ └── __init__.py
│ ├── tools
│ │ ├── __init__.py
│ │ ├── commands.py
│ │ ├── analysis_tools.py
│ │ └── claude_client.py
│ ├── __init__.py
│ └── main.py
├── .gitignore
├── pyproject.toml
├── README.md
└── LICENSE
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ └── issue-template.md
├── pull_request_template.md
└── workflows
│ └── build.yaml
├── vscode-extension
├── .gitignore
├── resources
│ ├── hyperdrive-logo.png
│ └── log-icon.svg
├── .vscodeignore
├── .vscode
│ ├── tasks.json
│ └── launch.json
├── tsconfig.json
├── CONTRIBUTING.md
├── webpack.config.js
├── src
│ ├── decorations.ts
│ ├── processor.ts
│ ├── variableDecorator.ts
│ ├── extension.ts
│ ├── rustLogParser.test.ts
│ ├── rustLogParser.ts
│ ├── variableExplorer.ts
│ ├── settingsView.ts
│ └── callStackExplorer.ts
├── package.json
└── LICENSE
├── README.md
└── LICENSE
/cli/.python-version:
--------------------------------------------------------------------------------
1 | traceback-cli-env
2 |
--------------------------------------------------------------------------------
/cli/tracebackapp/tui/__init__.py:
--------------------------------------------------------------------------------
1 | """TUI module for Traceback."""
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 |
--------------------------------------------------------------------------------
/cli/tracebackapp/tools/__init__.py:
--------------------------------------------------------------------------------
1 | """Tools for root cause analysis in the Traceback CLI."""
--------------------------------------------------------------------------------
/cli/tracebackapp/__init__.py:
--------------------------------------------------------------------------------
1 | """Traceback - A terminal-based AI chat interface."""
2 |
3 | __version__ = "0.1.0"
--------------------------------------------------------------------------------
/vscode-extension/.gitignore:
--------------------------------------------------------------------------------
1 | out
2 | dist
3 | node_modules
4 | *.log
5 | .vscode-test/
6 | *.vsix
7 |
8 | # JetBrains
9 | .idea/
10 |
--------------------------------------------------------------------------------
/vscode-extension/resources/hyperdrive-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/workbackai/traceback/HEAD/vscode-extension/resources/hyperdrive-logo.png
--------------------------------------------------------------------------------
/vscode-extension/.vscodeignore:
--------------------------------------------------------------------------------
1 | .vscode/**
2 | .github/**
3 | node_modules/**
4 | src/**
5 | .gitignore
6 | tsconfig.json
7 | package-lock.json
8 | **/*.ts
9 | **/*.map
10 | **/*.vsix
11 | .eslintrc*
12 | webpack.config.js
13 | out/**
14 | !dist/**
--------------------------------------------------------------------------------
/vscode-extension/resources/log-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue-template.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Add a New Issue
3 | about: Use this template to raise an issue.
4 | title: "[Issue Title]"
5 | labels:
6 | assignees:
7 | ---
8 |
9 | ### Expected Behavior
10 |
11 | Please describe the behavior you are expecting
12 |
13 | ### Current Behavior
14 |
15 | What is the current behavior?
16 |
--------------------------------------------------------------------------------
/vscode-extension/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "compile",
7 | "group": {
8 | "kind": "build",
9 | "isDefault": true
10 | },
11 | "presentation": {
12 | "reveal": "silent"
13 | },
14 | "problemMatcher": "$tsc"
15 | }
16 | ]
17 | }
--------------------------------------------------------------------------------
/vscode-extension/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "ES2020",
5 | "outDir": "out",
6 | "lib": [
7 | "ES2020"
8 | ],
9 | "sourceMap": true,
10 | "rootDir": "src",
11 | "strict": true,
12 | "esModuleInterop": true,
13 | "skipLibCheck": true
14 | },
15 | "exclude": [
16 | "node_modules",
17 | ".vscode-test"
18 | ]
19 | }
--------------------------------------------------------------------------------
/vscode-extension/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Run Extension",
6 | "type": "extensionHost",
7 | "request": "launch",
8 | "args": [
9 | "--extensionDevelopmentPath=${workspaceFolder}"
10 | ],
11 | "outFiles": [
12 | "${workspaceFolder}/dist/**/*.js"
13 | ],
14 | "sourceMaps": true,
15 | "preLaunchTask": "npm: compile"
16 | }
17 | ],
18 | "compounds": [
19 | {
20 | "name": "Extension with Compile",
21 | "configurations": ["Run Extension"]
22 | }
23 | ]
24 | }
--------------------------------------------------------------------------------
/cli/.gitignore:
--------------------------------------------------------------------------------
1 | # Python
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | build/
8 | develop-eggs/
9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 |
23 | # Virtual Environments
24 | .env
25 | .venv
26 | env/
27 | venv/
28 | ENV/
29 | env.bak/
30 | venv.bak/
31 |
32 | # Testing
33 | .pytest_cache/
34 | .coverage
35 | htmlcov/
36 | .tox/
37 | .nox/
38 |
39 | # Type checking
40 | .mypy_cache/
41 |
42 | # Editors
43 | .idea/
44 | .vscode/
45 | *.swp
46 | *.swo
47 | *~
48 |
49 | # OS specific
50 | .DS_Store
51 | Thumbs.db
--------------------------------------------------------------------------------
/vscode-extension/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Development
2 |
3 | ### Setup
4 |
5 | ```sh
6 | # Install dependencies
7 | npm install
8 |
9 | # Compile the extension
10 | npm run compile
11 |
12 | # Package the extension
13 | npm run package
14 | ```
15 |
16 | ### Run Extension
17 |
18 | 1. Build extension
19 |
20 | ```sh
21 | npm install
22 | npm run compile
23 | ```
24 |
25 | 2. Open directory in VS Code or Cursor
26 |
27 | ```sh
28 | cursor .
29 | # or
30 | code .
31 | ```
32 |
33 | 3. Launch extension
34 |
35 | 1. Press F5 to open a new window with your extension loaded
36 | 2. If you make changes to your extension, restart the extension development host
--------------------------------------------------------------------------------
/cli/tracebackapp/main.py:
--------------------------------------------------------------------------------
1 | """Main entry point for the Traceback CLI."""
2 |
3 | import typer
4 | from typing import Optional
5 |
6 | from tracebackapp.tui.app import TracebackApp
7 |
8 | app = typer.Typer(
9 | name="traceback",
10 | help="A terminal-based AI chat interface",
11 | add_completion=False,
12 | )
13 |
14 |
15 | @app.command()
16 | def main(
17 | debug: bool = typer.Option(
18 | False,
19 | "--debug",
20 | "-d",
21 | help="Enable debug mode",
22 | ),
23 | model: Optional[str] = typer.Option(
24 | None,
25 | "--model",
26 | "-m",
27 | help="Specify which AI model to use",
28 | ),
29 | ) -> None:
30 | """Launch the Traceback TUI."""
31 | traceback_app = TracebackApp()
32 | traceback_app.run()
33 |
34 |
35 | if __name__ == "__main__":
36 | app()
--------------------------------------------------------------------------------
/vscode-extension/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | target: 'node',
5 | mode: 'none', // Set to 'production' for release
6 | entry: './src/extension.ts',
7 | output: {
8 | path: path.resolve(__dirname, 'dist'),
9 | filename: 'extension.js',
10 | libraryTarget: 'commonjs2',
11 | devtoolModuleFilenameTemplate: '../[resource-path]'
12 | },
13 | devtool: 'source-map',
14 | externals: {
15 | vscode: 'commonjs vscode'
16 | },
17 | resolve: {
18 | extensions: ['.ts', '.js']
19 | },
20 | module: {
21 | rules: [
22 | {
23 | test: /\.ts$/,
24 | exclude: /node_modules/,
25 | use: [
26 | {
27 | loader: 'ts-loader',
28 | options: {
29 | compilerOptions: {
30 | sourceMap: true,
31 | }
32 | }
33 | }
34 | ]
35 | }
36 | ]
37 | }
38 | };
--------------------------------------------------------------------------------
/cli/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "tracebackapp"
7 | version = "0.1.0"
8 | description = "A terminal-based AI chat interface"
9 | readme = "README.md"
10 | requires-python = ">=3.8"
11 | license = {text = "Apache-2.0"}
12 | authors = [
13 | {name = "Traceback Team"}
14 | ]
15 | dependencies = [
16 | "textual>=0.38.1",
17 | "typer[all]>=0.9.0",
18 | "requests>=2.30.0",
19 | "anthropic>=0.18.0",
20 | ]
21 |
22 | [project.scripts]
23 | traceback = "tracebackapp.main:app"
24 |
25 | [project.optional-dependencies]
26 | dev = [
27 | "pytest>=7.3.1",
28 | "black>=23.3.0",
29 | "isort>=5.12.0",
30 | "mypy>=1.3.0",
31 | "ruff>=0.0.272",
32 | ]
33 |
34 | [tool.black]
35 | line-length = 88
36 | target-version = ["py38"]
37 |
38 | [tool.isort]
39 | profile = "black"
40 | line_length = 88
41 |
42 | [tool.mypy]
43 | python_version = "3.8"
44 | warn_return_any = true
45 | warn_unused_configs = true
46 | disallow_untyped_defs = true
47 | disallow_incomplete_defs = true
48 |
49 | [tool.ruff]
50 | line-length = 88
51 | target-version = "py38"
52 | select = ["E", "F", "B", "I"]
--------------------------------------------------------------------------------
/vscode-extension/src/decorations.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 |
3 | // Create decoration type for highlighting log lines
4 | export const logLineDecorationType = vscode.window.createTextEditorDecorationType({
5 | backgroundColor: 'rgba(255, 255, 0, 0.2)',
6 | isWholeLine: true,
7 | });
8 |
9 | // Create decoration type for variable values
10 | export const variableValueDecorationType = vscode.window.createTextEditorDecorationType({
11 | backgroundColor: 'rgba(65, 105, 225, 0.4)', // Made more opaque
12 | borderWidth: '2px', // Made thicker
13 | borderStyle: 'solid',
14 | borderColor: 'rgba(65, 105, 225, 0.7)', // Made more opaque
15 | after: {
16 | margin: '0 0 0 1em',
17 | contentText: 'test', // Added default text to test if decoration is working
18 | color: 'var(--vscode-editorInfo-foreground)',
19 | fontWeight: 'bold',
20 | },
21 | rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
22 | });
23 |
24 | export function clearDecorations(): void {
25 | // Clear all decorations
26 | for (const editor of vscode.window.visibleTextEditors) {
27 | editor.setDecorations(logLineDecorationType, []);
28 | editor.setDecorations(variableValueDecorationType, []);
29 | }
30 | }
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### Description
2 |
3 | _A few sentences describing the overall effects and goals of the pull request's commits.
4 | What is the current behavior, and what is the updated/expected behavior with this PR?_
5 |
6 | ### Other changes
7 |
8 | _Describe any minor or "drive-by" changes here._
9 |
10 | ### Tested
11 |
12 | _An explanation of how the changes were tested or an explanation as to why they don't need to be._
13 |
14 | ### Related issues
15 |
16 | - closes [ID]
17 |
18 |
27 |
28 | ### Backwards compatibility
29 |
30 | _Brief explanation of why these changes are/are not backwards compatible._
31 |
32 | ### Documentation
33 |
34 | _The set of community facing docs that have been added/modified because of this change_
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TraceBack
2 |
3 | A VS Code extension to debug using [`tracing`](https://docs.rs/tracing/latest/tracing/) logs (🦀+🐞)
4 |
5 | ## Demo
6 |
7 | 
8 |
9 | ## Quick Start
10 |
11 | 1. Install the [extension](https://marketplace.visualstudio.com/items/?itemName=hyperdrive-eng.traceback)
12 |
13 | 1. Open settings
14 |
15 | 
16 |
17 | 1. Add your Rust [`tracing`](https://docs.rs/tracing/latest/tracing/) logs
18 |
19 | 
20 |
21 | 1. Select your Rust repository
22 |
23 | 
24 |
25 | 1. Set your Claude API Key
26 |
27 | 
28 |
29 | ## Features
30 |
31 | 1. Visualise spans associated with a log
32 |
33 | 
34 |
35 | 1. Find the line of code associated with a log
36 |
37 | 
38 |
39 | 1. Navigate the call stack associated with a log
40 |
41 | 
42 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build and Release VSIX
2 |
3 | on:
4 | workflow_dispatch: # Trigger the workflow manually
5 | inputs:
6 | # Version MUST be in the format v*.*.*
7 | # Version MUST NOT already exist, else the workflow will fail
8 | version:
9 | description: 'Version number (v*.*.*)'
10 | required: true
11 | type: string
12 |
13 | permissions:
14 | # Allows the workflow to create releases, upload release assets, and manage repository contents
15 | contents: write
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 | defaults:
21 | run:
22 | working-directory: ./vscode-extension
23 | steps:
24 | # Documentation: https://github.com/actions/checkout
25 | - name: Checkout repository
26 | uses: actions/checkout@v4
27 |
28 | # Documentation: https://github.com/actions/setup-node
29 | - name: Setup Node.js
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: '20'
33 |
34 | - name: Install dependencies
35 | run: npm install
36 |
37 | - name: Package Extension
38 | run: npm run package
39 |
40 | # Documentation: https://github.com/softprops/action-gh-release
41 | - name: Create GitHub Release
42 | uses: softprops/action-gh-release@v2
43 | with:
44 | files: "vscode-extension/*.vsix"
45 | tag_name: ${{ github.event.inputs.version }}
46 |
47 | # Publish to VS Code Marketplace
48 | - name: Publish to VS Code Marketplace
49 | run: npx vsce publish
50 | env:
51 | VSCE_PAT: ${{ secrets.VSCE_PAT }}
52 |
--------------------------------------------------------------------------------
/cli/README.md:
--------------------------------------------------------------------------------
1 | # Traceback
2 |
3 | A terminal-based AI chat interface built with Textual.
4 |
5 | ## Features
6 |
7 | - Terminal UI for AI chat interactions
8 | - Support for interrupting AI responses mid-generation
9 | - Formatted text and code display
10 |
11 | ## Installation
12 |
13 | ### Development Installation
14 |
15 | 1. Clone the repository:
16 | ```bash
17 | git clone https://github.com/yourusername/traceback.git
18 | cd traceback
19 | ```
20 |
21 | 2. Create and activate a virtual environment:
22 | ```bash
23 | python -m venv .venv
24 | source .venv/bin/activate # On Windows: .venv\Scripts\activate
25 | ```
26 |
27 | 3. Install in development mode:
28 | ```bash
29 | pip install -e ".[dev]"
30 | ```
31 |
32 | 4. Launch the application:
33 | ```bash
34 | traceback
35 | ```
36 |
37 | 5. (Optional) Install dependencies
38 | ```bash
39 | pip install
40 | ```
41 |
42 | 6. (Optional) Freeze dependencies for others
43 | ```
44 | pip freeze > requirements.txt
45 | ```
46 |
47 | 7. Deactivate environment
48 | ```sh
49 | deactivate
50 | ```
51 |
52 | ### User Installation
53 |
54 | Install using pipx (recommended):
55 | ```bash
56 | pipx install tracebackapp
57 | ```
58 |
59 | Or using pip:
60 | ```bash
61 | pip install tracebackapp
62 | ```
63 |
64 | ## Usage
65 |
66 | Launch the application:
67 | ```bash
68 | traceback
69 | ```
70 |
71 | With options:
72 | ```bash
73 | traceback --model claude-3-opus # Specify a model
74 | traceback --debug # Enable debug mode
75 | ```
76 |
77 | ## Keyboard Shortcuts
78 |
79 | - `Ctrl+C` - Quit the application
80 | - `Ctrl+I` - Interrupt the current AI response
81 |
82 | ## Development
83 |
84 | ### Code Style
85 |
86 | The project uses:
87 | - Black for code formatting
88 | - isort for import sorting
89 | - mypy for type checking
90 | - ruff for linting
91 |
92 | Run checks:
93 | ```bash
94 | black .
95 | isort .
96 | mypy .
97 | ruff .
98 | ```
99 |
100 | ### Testing
101 |
102 | Run tests:
103 | ```bash
104 | pytest
105 | ```
106 |
107 |
108 |
--------------------------------------------------------------------------------
/vscode-extension/src/processor.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as vscode from 'vscode';
3 | import { RustLogEntry, RustSpan } from './logExplorer';
4 | import { RustLogParser } from './rustLogParser';
5 |
6 | /**
7 | * Create a default RustLogEntry for when parsing fails
8 | */
9 | function createDefaultRustLogEntry(message: string, level: 'ERROR' | 'DEBUG' | 'INFO' | 'TRACE' | 'WARN' = 'INFO'): RustLogEntry {
10 | const defaultSpan: RustSpan = {
11 | name: 'unknown',
12 | fields: []
13 | };
14 |
15 | return {
16 | level,
17 | message,
18 | timestamp: new Date().toISOString(),
19 | rawText: message,
20 | span_root: defaultSpan
21 | };
22 | }
23 |
24 | /**
25 | * Load logs from a file or pasted content and parse them into RustLogEntry objects
26 | */
27 | export async function loadLogs(content: string): Promise {
28 | const parser = new RustLogParser();
29 | const logs = await parser.parse(content);
30 | if (logs.length === 0) {
31 | throw new Error('No valid Rust logs found in content');
32 | }
33 | return logs;
34 | }
35 |
36 | export class LogProcessor {
37 | private _parser: RustLogParser;
38 | private _logEntries: RustLogEntry[] = [];
39 |
40 | constructor() {
41 | this._parser = new RustLogParser();
42 | }
43 |
44 | /**
45 | * Process a file and return log entries
46 | */
47 | async processFile(filePath: string): Promise {
48 | const content = fs.readFileSync(filePath, 'utf8');
49 | return this.processContent(content);
50 | }
51 |
52 | /**
53 | * Process content directly and return log entries
54 | */
55 | async processContent(content: string): Promise {
56 | const entries = await this._parser.parse(content);
57 | if (entries.length === 0) {
58 | throw new Error('No valid Rust logs found in content');
59 | }
60 | this._logEntries = entries;
61 | return entries;
62 | }
63 |
64 | /**
65 | * Get the current log entries
66 | */
67 | getLogEntries(): RustLogEntry[] {
68 | return this._logEntries;
69 | }
70 | }
--------------------------------------------------------------------------------
/vscode-extension/src/variableDecorator.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 | import * as fs from 'fs';
3 | import * as path from 'path';
4 | import { LLMLogAnalysis } from './claudeService';
5 | import { RustLogEntry } from './logExplorer';
6 | import {
7 | variableValueDecorationType,
8 | clearDecorations
9 | } from './decorations';
10 |
11 | /**
12 | * Class to handle decorating variables in the editor with their values from Rust logs
13 | */
14 | export class VariableDecorator {
15 | private _disposables: vscode.Disposable[] = [];
16 | private _decorationType: vscode.TextEditorDecorationType = variableValueDecorationType;
17 |
18 | constructor(private context: vscode.ExtensionContext) {}
19 |
20 | /**
21 | * Display a variable value in the editor based on Rust log entries
22 | */
23 | async decorateVariable(
24 | editor: vscode.TextEditor,
25 | log: RustLogEntry,
26 | variableName: string,
27 | value: unknown
28 | ): Promise {
29 | try {
30 | if (!log || !log.message) {
31 | console.warn('Invalid log entry provided');
32 | return;
33 | }
34 |
35 | const decorations: vscode.DecorationOptions[] = [];
36 |
37 | // Add decorations from the message
38 | this.addMessageDecorations(editor, log.message, variableName, value, decorations);
39 |
40 | // Add decorations from span fields
41 | if (log.span_root?.fields) {
42 | this.addSpanFieldDecorations(editor, log, variableName, decorations);
43 | }
44 |
45 | // Apply decorations
46 | editor.setDecorations(this._decorationType, decorations);
47 | } catch (error) {
48 | console.error('Error decorating variable:', error);
49 | // Clear any partial decorations
50 | editor.setDecorations(this._decorationType, []);
51 | }
52 | }
53 |
54 | private addMessageDecorations(
55 | editor: vscode.TextEditor,
56 | message: string,
57 | variableName: string,
58 | value: unknown,
59 | decorations: vscode.DecorationOptions[]
60 | ): void {
61 | const messageRegex = new RegExp(`\\b${variableName}\\b`, 'g');
62 | let match;
63 |
64 | while ((match = messageRegex.exec(message)) !== null) {
65 | const startPos = editor.document.positionAt(match.index);
66 | const endPos = editor.document.positionAt(match.index + variableName.length);
67 |
68 | decorations.push({
69 | range: new vscode.Range(startPos, endPos),
70 | renderOptions: {
71 | after: {
72 | contentText: ` = ${this.formatValue(value)}`,
73 | color: 'var(--vscode-editorInfo-foreground)'
74 | }
75 | }
76 | });
77 | }
78 | }
79 |
80 | private addSpanFieldDecorations(
81 | editor: vscode.TextEditor,
82 | log: RustLogEntry,
83 | variableName: string,
84 | decorations: vscode.DecorationOptions[]
85 | ): void {
86 | for (const field of log.span_root.fields) {
87 | if (field.name === variableName) {
88 | const index = log.message.indexOf(field.name);
89 | if (index !== -1) {
90 | const startPos = editor.document.positionAt(index);
91 | const endPos = editor.document.positionAt(index + field.name.length);
92 |
93 | decorations.push({
94 | range: new vscode.Range(startPos, endPos),
95 | renderOptions: {
96 | after: {
97 | contentText: ` = ${this.formatValue(field.value)}`,
98 | color: 'var(--vscode-editorInfo-foreground)'
99 | }
100 | }
101 | });
102 | }
103 | }
104 | }
105 | }
106 |
107 | private formatValue(value: unknown): string {
108 | if (typeof value === 'object' && value !== null) {
109 | return JSON.stringify(value, null, 2);
110 | }
111 | return String(value);
112 | }
113 |
114 | public dispose(): void {
115 | while (this._disposables.length) {
116 | const x = this._disposables.pop();
117 | if (x) {
118 | x.dispose();
119 | }
120 | }
121 | }
122 | }
--------------------------------------------------------------------------------
/vscode-extension/src/extension.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { LogExplorerProvider } from "./logExplorer";
3 | import { registerVariableExplorer } from "./variableExplorer";
4 | import { VariableDecorator } from "./variableDecorator";
5 | import { registerCallStackExplorer } from "./callStackExplorer";
6 | import { SpanVisualizerPanel } from "./spanVisualizerPanel";
7 | import { SettingsView } from "./settingsView";
8 |
9 | export function activate(context: vscode.ExtensionContext) {
10 | console.log("TraceBack is now active");
11 |
12 | const updateStatusBars = () => {};
13 |
14 | // Create other providers
15 | const logExplorerProvider = new LogExplorerProvider(context);
16 | const variableExplorerProvider = registerVariableExplorer(context);
17 | const callStackExplorerProvider = registerCallStackExplorer(context);
18 | const variableDecorator = new VariableDecorator(context);
19 |
20 | // Register the tree view
21 | const treeView = vscode.window.createTreeView("logExplorer", {
22 | treeDataProvider: logExplorerProvider,
23 | showCollapseAll: false,
24 | });
25 |
26 | // Connect providers
27 | variableExplorerProvider.setVariableDecorator(variableDecorator);
28 | logExplorerProvider.setVariableExplorer(variableExplorerProvider);
29 | logExplorerProvider.setCallStackExplorer(callStackExplorerProvider);
30 |
31 | // Register commands
32 | const refreshCommand = vscode.commands.registerCommand("traceback.refreshLogs", () => {
33 | logExplorerProvider.refresh();
34 | });
35 |
36 | const showLogsCommand = vscode.commands.registerCommand("traceback.showLogs", () => {
37 | vscode.commands.executeCommand("workbench.view.extension.traceback");
38 | updateStatusBars();
39 | logExplorerProvider.refresh();
40 | });
41 |
42 | const filterCommand = vscode.commands.registerCommand("traceback.filterLogs", () => {
43 | logExplorerProvider.selectLogLevels();
44 | });
45 |
46 | // Command to set log file path
47 | const setLogPathCommand = vscode.commands.registerCommand(
48 | "traceback.setLogPath",
49 | async () => {
50 | // Use file picker instead of input box
51 | const options: vscode.OpenDialogOptions = {
52 | canSelectFiles: true,
53 | canSelectFolders: false,
54 | canSelectMany: false,
55 | openLabel: "Select Log File",
56 | filters: {
57 | "Log Files": ["log", "json"],
58 | "All Files": ["*"],
59 | },
60 | };
61 |
62 | const fileUri = await vscode.window.showOpenDialog(options);
63 | if (fileUri && fileUri[0]) {
64 | const logPath = fileUri[0].fsPath;
65 | await context.globalState.update("logFilePath", logPath);
66 | updateStatusBars();
67 | logExplorerProvider.refresh();
68 | }
69 | }
70 | );
71 |
72 | // Command to set repository path
73 | const setRepoPathCommand = vscode.commands.registerCommand(
74 | "traceback.setRepoPath",
75 | async () => {
76 | const options: vscode.OpenDialogOptions = {
77 | canSelectFiles: false,
78 | canSelectFolders: true,
79 | canSelectMany: false,
80 | openLabel: "Select Repository Root",
81 | title: "Select Repository Root Directory",
82 | };
83 |
84 | const fileUri = await vscode.window.showOpenDialog(options);
85 | if (fileUri && fileUri[0]) {
86 | const repoPath = fileUri[0].fsPath;
87 | await context.globalState.update("repoPath", repoPath);
88 |
89 | // Open the selected folder in VS Code
90 | await vscode.commands.executeCommand("vscode.openFolder", fileUri[0], {
91 | forceNewWindow: false, // Set to true if you want to open in a new window
92 | });
93 |
94 | // Show confirmation message
95 | vscode.window.showInformationMessage(`Repository path set to: ${repoPath}`);
96 | updateStatusBars(); // Update status bars
97 | logExplorerProvider.refresh();
98 | }
99 | }
100 | );
101 |
102 | // Command to reset log file path
103 | const resetLogPathCommand = vscode.commands.registerCommand(
104 | "traceback.resetLogPath",
105 | async () => {
106 | await context.globalState.update("logFilePath", undefined);
107 | updateStatusBars(); // Update all status bars
108 | logExplorerProvider.refresh();
109 | }
110 | );
111 |
112 | // Command to clear the views
113 | const clearExplorersCommand = vscode.commands.registerCommand(
114 | "traceback.clearExplorers",
115 | () => {
116 | variableExplorerProvider.setLog(undefined);
117 | callStackExplorerProvider.setLogEntry(undefined);
118 | }
119 | );
120 |
121 | // Register settings command
122 | const openSettingsCommand = vscode.commands.registerCommand(
123 | "traceback.openSettings",
124 | () => {
125 | SettingsView.createOrShow(context);
126 | }
127 | );
128 |
129 | const openCallStackLocationCommand = vscode.commands.registerCommand(
130 | 'traceback.openCallStackLocation',
131 | (caller, treeItem) => {
132 | callStackExplorerProvider.openCallStackLocation(caller, treeItem);
133 | }
134 | );
135 |
136 | // Add new command to show span visualizer
137 | const showSpanVisualizerCommand = vscode.commands.registerCommand(
138 | "traceback.showSpanVisualizer",
139 | () => {
140 | // Get the current logs from the LogExplorerProvider
141 | const logs = logExplorerProvider.getLogs();
142 | SpanVisualizerPanel.createOrShow(context, logs);
143 | }
144 | );
145 |
146 | // Register the span visualizer command
147 | context.subscriptions.push(
148 | vscode.commands.registerCommand('traceback.openSpanVisualizer', () => {
149 | const logs = logExplorerProvider.getLogs();
150 | SpanVisualizerPanel.createOrShow(context, logs);
151 | })
152 | );
153 |
154 | // Register the filter by span command
155 | context.subscriptions.push(
156 | vscode.commands.registerCommand('traceback.filterBySpan', (spanName: string) => {
157 | logExplorerProvider.filterBySpan(spanName);
158 | })
159 | );
160 |
161 | context.subscriptions.push(
162 | treeView,
163 | openSettingsCommand,
164 | refreshCommand,
165 | showLogsCommand,
166 | filterCommand,
167 | setLogPathCommand,
168 | setRepoPathCommand,
169 | resetLogPathCommand,
170 | clearExplorersCommand,
171 | openCallStackLocationCommand,
172 | showSpanVisualizerCommand
173 | );
174 |
175 | // Initial refresh
176 | updateStatusBars();
177 | logExplorerProvider.refresh();
178 | }
179 |
180 | /**
181 | * The deactivate() function should stay in your extension, even if it's empty. This is because it's part of VS Code's extension API contract - VS Code expects to find both activate() and deactivate() functions exported from your main extension file.
182 | * All disposables (commands, tree view, status bar items) are properly managed through the context.subscriptions array in the activate() function, so VS Code will automatically clean those up. Therefore, having an empty deactivate() function is actually acceptable in this case.
183 | */
184 | export function deactivate() {}
185 |
--------------------------------------------------------------------------------
/cli/tracebackapp/tools/commands.py:
--------------------------------------------------------------------------------
1 | """Command handlers for the root cause analysis tools."""
2 |
3 | from typing import Dict, Any, List, Optional
4 | from .analysis_tools import Analyzer
5 |
6 | class RootCauseCommands:
7 | """Command handlers for the root cause analysis tools in the Traceback CLI."""
8 |
9 | def __init__(self):
10 | """Initialize the command handlers."""
11 | self.analyzer = Analyzer()
12 | self.current_options = [] # Store current options for user selection
13 |
14 | def handle_command(self, command: str, args: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15 | """
16 | Handle a root cause analysis command.
17 |
18 | Args:
19 | command: The command to execute
20 | args: Arguments for the command
21 |
22 | Returns:
23 | Dictionary with the command results
24 | """
25 | args = args or {}
26 |
27 | # Map commands to their handlers
28 | command_map = {
29 | "/analyze": self._analyze_root_cause,
30 | "/options": self._present_options,
31 | "/log": self._analyze_log_line,
32 | "/stack": self._get_stack_trace,
33 | "/callers": self._get_callers,
34 | "/code": self._send_code,
35 | "/select": self._select_option
36 | }
37 |
38 | # Execute the command if it exists
39 | if command in command_map:
40 | return command_map[command](args)
41 |
42 | return {
43 | "status": "error",
44 | "message": f"Unknown command: {command}"
45 | }
46 |
47 | def _analyze_root_cause(self, args: Dict[str, Any]) -> Dict[str, Any]:
48 | """Handle the analyze root cause command using the new Analyzer."""
49 | logs = args.get("logs")
50 |
51 | if not logs:
52 | return {
53 | "status": "error",
54 | "message": "No logs provided for analysis"
55 | }
56 |
57 | # Note: We don't call analyzer.analyze() directly here
58 | # because that's handled by _analyze_logs_with_llm in the TUI
59 |
60 | return {
61 | "status": "success",
62 | "result": {
63 | "analysis": "Analysis initiated. Results will be displayed incrementally."
64 | }
65 | }
66 |
67 | def _present_options(self, args: Dict[str, Any]) -> Dict[str, Any]:
68 | """Handle the present options command."""
69 | options = args.get("options", self.current_options)
70 | result = self.analyzer.present_options(options)
71 |
72 | # Store the options for later selection
73 | self.current_options = options
74 |
75 | return {
76 | "status": "success",
77 | "options": result
78 | }
79 |
80 | def _analyze_log_line(self, args: Dict[str, Any]) -> Dict[str, Any]:
81 | """Handle the analyze log line command."""
82 | log_line = args.get("log_line", "")
83 | if not log_line:
84 | return {
85 | "status": "error",
86 | "message": "No log line provided"
87 | }
88 |
89 | result = self.analyzer.analyze_log_line(log_line)
90 | return {
91 | "status": "success",
92 | "result": result
93 | }
94 |
95 | def _get_stack_trace(self, args: Dict[str, Any]) -> Dict[str, Any]:
96 | """Handle the get stack trace command."""
97 | code_location = args.get("code_location", "")
98 | if not code_location:
99 | return {
100 | "status": "error",
101 | "message": "No code location provided"
102 | }
103 |
104 | result = self.analyzer.get_stack_trace(code_location)
105 | return {
106 | "status": "success",
107 | "result": result
108 | }
109 |
110 | def _get_callers(self, args: Dict[str, Any]) -> Dict[str, Any]:
111 | """Handle the get callers command."""
112 | code_location = args.get("code_location", "")
113 | if not code_location:
114 | return {
115 | "status": "error",
116 | "message": "No code location provided"
117 | }
118 |
119 | result = self.analyzer.get_callers(code_location)
120 | return {
121 | "status": "success",
122 | "result": result
123 | }
124 |
125 | def _send_code(self, args: Dict[str, Any]) -> Dict[str, Any]:
126 | """Handle the send code command."""
127 | code_location = args.get("code_location", "")
128 | if not code_location:
129 | return {
130 | "status": "error",
131 | "message": "No code location provided"
132 | }
133 |
134 | context_lines = args.get("context_lines", 20)
135 | result = self.analyzer.send_code(code_location, context_lines)
136 | return {
137 | "status": "success",
138 | "result": result
139 | }
140 |
141 | def _select_option(self, args: Dict[str, Any]) -> Dict[str, Any]:
142 | """Handle option selection."""
143 | option_id = args.get("option_id")
144 | if option_id is None:
145 | return {
146 | "status": "error",
147 | "message": "No option ID provided"
148 | }
149 |
150 | # Convert to int if it's a string
151 | try:
152 | option_id = int(option_id)
153 | except ValueError:
154 | return {
155 | "status": "error",
156 | "message": f"Invalid option ID: {option_id}"
157 | }
158 |
159 | # Check if the option ID is valid
160 | if not self.current_options or option_id < 1 or option_id > len(self.current_options):
161 | return {
162 | "status": "error",
163 | "message": f"Invalid option ID: {option_id}"
164 | }
165 |
166 | # Get the selected option
167 | selected_option = self.current_options[option_id - 1]
168 |
169 | # Based on the option type, take appropriate action
170 | option_type = selected_option.get("type", "")
171 |
172 | if option_type == "log_segment":
173 | # Analyze the log segment
174 | return self._analyze_root_cause({
175 | "logs": selected_option.get("segment", "")
176 | })
177 | elif option_type == "send_code":
178 | # Send the code
179 | return self._send_code({
180 | "code_location": f"{selected_option.get('file_path')}:{selected_option.get('line', 1)}"
181 | })
182 | elif option_type == "get_callers":
183 | # Get the callers
184 | return self._get_callers({
185 | "code_location": f"{selected_option.get('file_path')}:{selected_option.get('line', 1)}"
186 | })
187 |
188 | return {
189 | "status": "success",
190 | "message": f"Selected option {option_id}: {selected_option.get('message', '')}"
191 | }
--------------------------------------------------------------------------------
/vscode-extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "traceback",
3 | "displayName": "TraceBack",
4 | "description": "A VS Code extension that brings telemetry data (traces, logs, and metrics) into your code.",
5 | "version": "0.5.0",
6 | "publisher": "hyperdrive-eng",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/hyperdrive-eng/traceback.git"
10 | },
11 | "license": "Apache-2.0",
12 | "engines": {
13 | "vscode": "^1.74.0"
14 | },
15 | "icon": "./resources/hyperdrive-logo.png",
16 | "categories": [
17 | "Debuggers",
18 | "Visualization"
19 | ],
20 | "activationEvents": [
21 | "onView:logExplorer",
22 | "onView:logVariableExplorer",
23 | "onView:callStackExplorer",
24 | "onCommand:traceback.showLogs",
25 | "onCommand:traceback.refreshLogs",
26 | "onCommand:traceback.createSampleLogs",
27 | "onCommand:traceback.loadJaegerTrace",
28 | "onCommand:traceback.setJaegerEndpoint",
29 | "onCommand:traceback.loadAxiomTrace",
30 | "onCommand:traceback.setAxiomDataset",
31 | "onCommand:traceback.openSettings"
32 | ],
33 | "main": "./dist/extension.js",
34 | "contributes": {
35 | "configuration": {
36 | "title": "TraceBack",
37 | "properties": {
38 | "traceback.claudeApiKey": {
39 | "type": "string",
40 | "default": "",
41 | "description": "API key for Claude AI service"
42 | }
43 | }
44 | },
45 | "commands": [
46 | {
47 | "command": "traceback.setClaudeApiKey",
48 | "title": "Set Claude API Key",
49 | "category": "TraceBack"
50 | },
51 | {
52 | "command": "traceback.filterLogs",
53 | "title": "Filter Log Levels",
54 | "icon": "$(filter)"
55 | },
56 | {
57 | "command": "traceback.toggleSort",
58 | "title": "Toggle Sort Mode",
59 | "icon": "$(sort-precedence)"
60 | },
61 | {
62 | "command": "traceback.refreshLogs",
63 | "title": "Refresh Logs",
64 | "icon": "$(refresh)"
65 | },
66 | {
67 | "command": "traceback.setRepoPath",
68 | "title": "Set Repository Root",
69 | "icon": "$(folder)"
70 | },
71 | {
72 | "command": "traceback.clearExplorers",
73 | "title": "Clear Views",
74 | "icon": "$(clear-all)"
75 | },
76 | {
77 | "command": "traceback.copyVariableValue",
78 | "title": "Copy Variable Value"
79 | },
80 | {
81 | "command": "traceback.copySpanValue",
82 | "title": "Copy Span Value"
83 | },
84 | {
85 | "command": "traceback.loadJaegerTrace",
86 | "title": "Load Jaeger Trace",
87 | "icon": "$(globe)"
88 | },
89 | {
90 | "command": "traceback.setJaegerEndpoint",
91 | "title": "Set Jaeger API Endpoint",
92 | "icon": "$(gear)"
93 | },
94 | {
95 | "command": "traceback.loadAxiomTrace",
96 | "title": "Load Axiom Trace",
97 | "icon": "$(server)"
98 | },
99 | {
100 | "command": "traceback.setAxiomDataset",
101 | "title": "Set Axiom Dataset",
102 | "icon": "$(gear)"
103 | },
104 | {
105 | "command": "traceback.openSettings",
106 | "title": "Open TraceBack Settings",
107 | "category": "TraceBack",
108 | "icon": "$(settings-gear)"
109 | },
110 | {
111 | "command": "traceback.inspectVariableFromContext",
112 | "title": "Inspect Value",
113 | "icon": "$(eye)"
114 | },
115 | {
116 | "command": "traceback.showSpanVisualizer",
117 | "title": "Show Span Visualizer",
118 | "category": "TraceBack"
119 | },
120 | {
121 | "command": "traceback.importLogs",
122 | "title": "Import Logs from File",
123 | "category": "Traceback",
124 | "icon": "$(file-add)"
125 | },
126 | {
127 | "command": "traceback.pasteLogs",
128 | "title": "Import Logs from Clipboard",
129 | "category": "Traceback",
130 | "icon": "$(clippy)"
131 | }
132 | ],
133 | "viewsContainers": {
134 | "activitybar": [
135 | {
136 | "id": "traceback",
137 | "title": "TraceBack",
138 | "icon": "resources/log-icon.svg"
139 | }
140 | ]
141 | },
142 | "views": {
143 | "traceback": [
144 | {
145 | "id": "logExplorer",
146 | "name": "Logs",
147 | "icon": "$(list-unordered)",
148 | "contextualTitle": "Logs Explorer"
149 | },
150 | {
151 | "id": "logVariableExplorer",
152 | "name": "Variables",
153 | "icon": "$(symbol-variable)",
154 | "contextualTitle": "Variable Explorer"
155 | },
156 | {
157 | "id": "callStackExplorer",
158 | "name": "Call Stack",
159 | "icon": "$(callstack-view-icon)",
160 | "contextualTitle": "Call Stack Explorer"
161 | }
162 | ]
163 | },
164 | "menus": {
165 | "view/title": [
166 | {
167 | "command": "traceback.filterLogs",
168 | "when": "view == logExplorer",
169 | "group": "navigation@1"
170 | },
171 | {
172 | "command": "traceback.refreshLogs",
173 | "when": "view == logExplorer",
174 | "group": "navigation@2"
175 | },
176 | {
177 | "command": "traceback.openSettings",
178 | "when": "view == logExplorer",
179 | "group": "navigation@3"
180 | },
181 | {
182 | "command": "traceback.showSpanVisualizer",
183 | "when": "view == logExplorer",
184 | "group": "navigation@4"
185 | },
186 | {
187 | "command": "traceback.importLogs",
188 | "when": "view == tracebackLogs",
189 | "group": "navigation"
190 | },
191 | {
192 | "command": "traceback.pasteLogs",
193 | "when": "view == tracebackLogs",
194 | "group": "navigation"
195 | }
196 | ],
197 | "view/item/context": [
198 | {
199 | "command": "traceback.copySpanValue",
200 | "when": "viewItem == spanDetail",
201 | "group": "inline"
202 | },
203 | {
204 | "command": "traceback.inspectVariableFromContext",
205 | "title": "Inspect Value",
206 | "when": "viewItem =~ /.*-inspectable$/",
207 | "group": "inline@1"
208 | }
209 | ]
210 | },
211 | "keybindings": [],
212 | "commandPalette": [
213 | {
214 | "command": "traceback.openSettings",
215 | "title": "TraceBack: Open Settings"
216 | },
217 | {
218 | "command": "traceback.showSpanVisualizer",
219 | "title": "TraceBack: Show Span Visualizer"
220 | }
221 | ]
222 | },
223 | "scripts": {
224 | "vscode:prepublish": "webpack --mode production",
225 | "compile": "webpack --mode development",
226 | "watch": "webpack --mode development --watch",
227 | "pretest": "npm run compile",
228 | "test": "jest",
229 | "package": "vsce package",
230 | "lint": "eslint src --ext ts"
231 | },
232 | "jest": {
233 | "preset": "ts-jest",
234 | "testEnvironment": "node",
235 | "testMatch": [
236 | "/src/**/*.test.ts"
237 | ],
238 | "moduleFileExtensions": [
239 | "ts",
240 | "js"
241 | ]
242 | },
243 | "devDependencies": {
244 | "@types/glob": "^7.2.0",
245 | "@types/jest": "^29.5.14",
246 | "@types/node": "^16.18.36",
247 | "@types/node-fetch": "^2.6.12",
248 | "@types/vscode": "^1.74.0",
249 | "@typescript-eslint/eslint-plugin": "^5.59.11",
250 | "@typescript-eslint/parser": "^5.59.11",
251 | "@vscode/vsce": "^3.3.2",
252 | "eslint": "^8.42.0",
253 | "glob": "^8.1.0",
254 | "jest": "^29.7.0",
255 | "ts-jest": "^29.3.2",
256 | "ts-loader": "^9.5.2",
257 | "typescript": "^5.1.3",
258 | "webpack": "^5.99.3",
259 | "webpack-cli": "^6.0.1"
260 | },
261 | "dependencies": {
262 | "@axiomhq/js": "^1.3.1",
263 | "chalk": "^4.1.2",
264 | "dayjs": "^1.11.9",
265 | "node-fetch": "^2.6.7"
266 | }
267 | }
268 |
--------------------------------------------------------------------------------
/vscode-extension/src/rustLogParser.test.ts:
--------------------------------------------------------------------------------
1 | import { RustLogParser } from './rustLogParser';
2 | import '@jest/globals';
3 |
4 | describe('RustLogParser', () => {
5 | let parser: RustLogParser;
6 |
7 | beforeEach(() => {
8 | parser = new RustLogParser();
9 | });
10 |
11 | test('parses complex span chain with nested fields', async () => {
12 | const logLine = '2025-04-20T03:16:50.160897Z TRACE event_loop:startup:release_tag_downstream{tag=[-1ns+18446744073709551615]}: boomerang_runtime::sched: Releasing downstream downstream=EnclaveKey(1) event=TagRelease[enclave=EnclaveKey(2),tag=[-1ns+18446744073709551615]]';
13 |
14 | const logs = await parser.parse(logLine);
15 | expect(logs).toHaveLength(1);
16 |
17 | const log = logs[0];
18 | expect(log.timestamp).toBe('2025-04-20T03:16:50.160897Z');
19 | expect(log.level).toBe('TRACE');
20 |
21 | // Verify span chain
22 | expect(log.span_root.name).toBe('event_loop');
23 | expect(log.span_root.child?.name).toBe('startup');
24 | expect(log.span_root.child?.child?.name).toBe('release_tag_downstream');
25 | expect(log.span_root.child?.child?.fields).toHaveLength(1);
26 | expect(log.span_root.child?.child?.fields[0]).toEqual({
27 | name: 'tag',
28 | value: '[-1ns+18446744073709551615]'
29 | });
30 |
31 | // Message should not include the module path prefix but preserve event data
32 | expect(log.message).toBe('Releasing downstream downstream=EnclaveKey(1) event=TagRelease[enclave=EnclaveKey(2),tag=[-1ns+18446744073709551615]]');
33 | });
34 |
35 | test('parses log lines with ANSI color codes', async () => {
36 | const logLine = '\u001b[2m2025-04-20T03:16:50.160295Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mboomerang_builder::env::build\u001b[0m\u001b[2m:\u001b[0m Action enclave_cycle::__shutdown is unused, won\'t build';
37 |
38 | const logs = await parser.parse(logLine);
39 | expect(logs).toHaveLength(1);
40 |
41 | const log = logs[0];
42 | expect(log.timestamp).toBe('2025-04-20T03:16:50.160295Z');
43 | expect(log.level).toBe('INFO');
44 | expect(log.target).toBe('boomerang_builder::env::build');
45 | expect(log.span_root.name).toBe('boomerang_builder::env::build');
46 | // Message should preserve module paths that are part of the actual message
47 | expect(log.message).toBe('Action enclave_cycle::__shutdown is unused, won\'t build');
48 |
49 | // Original text with ANSI codes should be preserved
50 | expect(log.rawText).toBe(logLine);
51 | });
52 |
53 | test('parses simple log with module path in message', async () => {
54 | const logLine = '2025-04-20T03:16:50.160355Z INFO boomerang_builder::env::build: Action enclave_cycle::con_reactor_src::__startup is unused, won\'t build';
55 |
56 | const logs = await parser.parse(logLine);
57 | expect(logs).toHaveLength(1);
58 |
59 | const log = logs[0];
60 | expect(log.timestamp).toBe('2025-04-20T03:16:50.160355Z');
61 | expect(log.level).toBe('INFO');
62 | expect(log.target).toBe('boomerang_builder::env::build');
63 | // Message should preserve module paths that are part of the actual message
64 | expect(log.message).toBe('Action enclave_cycle::con_reactor_src::__startup is unused, won\'t build');
65 | });
66 |
67 | test('parses Neon log format with multiple fields', async () => {
68 | const logLine = '2025-03-31T14:38:43.945268Z WARN ephemeral_file_buffered_writer{tenant_id=3a885e0a8859fb7839d911d0143dca24 shard_id=0000 timeline_id=e26d9e4c6cd04a9c0b613ef7d1b77b9e path=/tmp/test_output/test_pageserver_catchup_while_compute_down[release-pg15]-1/repo/pageserver_1/tenants/3a885e0a8859fb7839d911d0143dca24/timelines/e26d9e4c6cd04a9c0b613ef7d1b77b9e/ephemeral-2}:flush_attempt{attempt=1}: error flushing buffered writer buffer to disk, retrying after backoff err=Operation canceled (os error 125)';
69 |
70 | const logs = await parser.parse(logLine);
71 | expect(logs).toHaveLength(1);
72 |
73 | const log = logs[0];
74 | expect(log.timestamp).toBe('2025-03-31T14:38:43.945268Z');
75 | expect(log.level).toBe('WARN');
76 |
77 | // Verify root span
78 | expect(log.span_root.name).toBe('ephemeral_file_buffered_writer');
79 | expect(log.span_root.fields).toHaveLength(4);
80 | expect(log.span_root.fields).toEqual([
81 | { name: 'tenant_id', value: '3a885e0a8859fb7839d911d0143dca24' },
82 | { name: 'shard_id', value: '0000' },
83 | { name: 'timeline_id', value: 'e26d9e4c6cd04a9c0b613ef7d1b77b9e' },
84 | { name: 'path', value: '/tmp/test_output/test_pageserver_catchup_while_compute_down[release-pg15]-1/repo/pageserver_1/tenants/3a885e0a8859fb7839d911d0143dca24/timelines/e26d9e4c6cd04a9c0b613ef7d1b77b9e/ephemeral-2' }
85 | ]);
86 |
87 | // Verify child span
88 | expect(log.span_root.child?.name).toBe('flush_attempt');
89 | expect(log.span_root.child?.fields).toHaveLength(1);
90 | expect(log.span_root.child?.fields[0]).toEqual({
91 | name: 'attempt',
92 | value: '1'
93 | });
94 |
95 | expect(log.message).toBe('error flushing buffered writer buffer to disk, retrying after backoff err=Operation canceled (os error 125)');
96 | });
97 |
98 | test('parses JSON format log', async () => {
99 | const logLine = '{"timestamp":"2025-04-23T11:40:40.926094Z","level":"INFO","fields":{"message":"Action enclave_cycle::__startup is unused, won\'t build"},"target":"boomerang_builder::env::build","filename":"boomerang_builder/src/env/build.rs","line_number":244}';
100 |
101 | const logs = await parser.parse(logLine);
102 | expect(logs).toHaveLength(1);
103 |
104 | const log = logs[0];
105 | expect(log.timestamp).toBe('2025-04-23T11:40:40.926094Z');
106 | expect(log.level).toBe('INFO');
107 | expect(log.target).toBe('boomerang_builder::env::build');
108 | expect(log.message).toBe('Action enclave_cycle::__startup is unused, won\'t build');
109 | expect(log.rawText).toBe(logLine);
110 | });
111 |
112 | test('parses JSON format log with additional fields', async () => {
113 | const logLine = '{"timestamp":"2025-04-23T11:40:40.926094Z","level":"INFO","fields":{"message":"Processing request","request_id":"123","user":"alice"},"target":"api::handler"}';
114 |
115 | const logs = await parser.parse(logLine);
116 | expect(logs).toHaveLength(1);
117 |
118 | const log = logs[0];
119 | expect(log.timestamp).toBe('2025-04-23T11:40:40.926094Z');
120 | expect(log.level).toBe('INFO');
121 | expect(log.target).toBe('api::handler');
122 | expect(log.message).toBe('Processing request');
123 |
124 | // Additional fields should be captured in span_root.fields
125 | expect(log.span_root.fields).toHaveLength(2);
126 | expect(log.span_root.fields).toContainEqual({ name: 'request_id', value: '123' });
127 | expect(log.span_root.fields).toContainEqual({ name: 'user', value: 'alice' });
128 | });
129 |
130 | test('parses JSON format log with source location', async () => {
131 | const logLine = '{"timestamp":"2025-04-23T11:40:40.926094Z","level":"INFO","fields":{"message":"Releasing downstream downstream=EnclaveKey(1) event=TagRelease[enclave=EnclaveKey(2),tag=[-1ns+18446744073709551615]]"},"target":"boomerang_runtime::sched","filename":"boomerang_runtime/src/sched.rs","line_number":244}';
132 |
133 | const logs = await parser.parse(logLine);
134 | expect(logs).toHaveLength(1);
135 |
136 | const log = logs[0];
137 | expect(log.timestamp).toBe('2025-04-23T11:40:40.926094Z');
138 | expect(log.level).toBe('INFO');
139 | expect(log.target).toBe('boomerang_runtime::sched');
140 | expect(log.message).toBe('Releasing downstream downstream=EnclaveKey(1) event=TagRelease[enclave=EnclaveKey(2),tag=[-1ns+18446744073709551615]]');
141 |
142 | // Verify source location is parsed correctly
143 | expect(log.source_location).toBeDefined();
144 | expect(log.source_location).toEqual({
145 | file: 'boomerang_runtime/src/sched.rs',
146 | line: 244
147 | });
148 | });
149 | });
--------------------------------------------------------------------------------
/vscode-extension/src/rustLogParser.ts:
--------------------------------------------------------------------------------
1 | import { RustLogEntry, RustSpan, RustSpanField } from './logExplorer';
2 |
3 | export class RustLogParser {
4 | // Regex for matching Rust tracing format with support for span fields
5 | private static readonly SPAN_LOG_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,9}Z)\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(?:\[([^\]]+)\])?\s*([^:]+(?:\{[^}]+\})?(?::[^:]+(?:\{[^}]+\})?)*): (.+)$/;
6 |
7 | // Regex for simpler log format (updated to handle module paths)
8 | private static readonly SIMPLE_LOG_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,9}Z)\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+([^:\s]+(?:::[^:\s]+)*)\s*:\s*(.+)$/;
9 |
10 | // Regex for ANSI escape codes
11 | private static readonly ANSI_REGEX = /\u001b\[[0-9;]*[mGK]/g;
12 |
13 | async parse(content: string): Promise {
14 | const lines = content.split('\n')
15 | .map(line => line.trim())
16 | .filter(line => line.length > 0);
17 |
18 | const logs: RustLogEntry[] = [];
19 |
20 | for (const line of lines) {
21 | // Strip ANSI escape codes before parsing
22 | const cleanLine = line.replace(RustLogParser.ANSI_REGEX, '');
23 |
24 | // First try parsing as JSON
25 | const jsonLog = this.parseJsonLogLine(cleanLine);
26 | if (jsonLog) {
27 | jsonLog.rawText = line; // preserve original line
28 | logs.push(jsonLog);
29 | continue;
30 | }
31 |
32 | // Fall back to regex parsing if not JSON
33 | const rustLog = this.parseRustLogLine(cleanLine);
34 | if (rustLog) {
35 | rustLog.rawText = line;
36 | logs.push(rustLog);
37 | }
38 | }
39 |
40 | return logs;
41 | }
42 |
43 | /**
44 | * Try to parse a log line as JSON format
45 | */
46 | private parseJsonLogLine(line: string): RustLogEntry | null {
47 | try {
48 | const json = JSON.parse(line);
49 |
50 | // Validate required fields
51 | if (!json.timestamp || !json.level || !json.target || !json.fields?.message) {
52 | return null;
53 | }
54 |
55 | // Convert JSON log to RustLogEntry format
56 | return {
57 | timestamp: json.timestamp,
58 | level: json.level.toUpperCase() as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR',
59 | target: json.target,
60 | message: json.fields.message,
61 | span_root: {
62 | name: json.target,
63 | fields: Object.entries(json.fields)
64 | .filter(([key]) => key !== 'message')
65 | .map(([key, value]) => ({
66 | name: key,
67 | value: String(value)
68 | }))
69 | },
70 | rawText: line,
71 | // Optional source location if available
72 | source_location: json.filename && json.line_number ? {
73 | file: json.filename,
74 | line: json.line_number
75 | } : undefined
76 | };
77 | } catch (error) {
78 | return null;
79 | }
80 | }
81 |
82 | private parseRustLogLine(line: string): RustLogEntry | null {
83 | // Try parsing as a span chain log first
84 | const spanMatch = line.match(RustLogParser.SPAN_LOG_REGEX);
85 | if (spanMatch) {
86 | const [_, timestamp, level, target, spanChain, message] = spanMatch;
87 | try {
88 | const span_root = this.parseSpanChain(spanChain);
89 | // Extract any module path from the message
90 | const cleanMessage = this.stripModulePath(message.trim());
91 | return {
92 | timestamp,
93 | level: level as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR',
94 | target: target || 'unknown',
95 | span_root,
96 | message: cleanMessage,
97 | rawText: line,
98 | };
99 | } catch (error) {
100 | console.debug('Failed to parse span chain:', error);
101 | }
102 | }
103 |
104 | // If that fails, try parsing as a simple log
105 | const simpleMatch = line.match(RustLogParser.SIMPLE_LOG_REGEX);
106 | if (simpleMatch) {
107 | const [_, timestamp, level, target, message] = simpleMatch;
108 | // Extract any module path from the message
109 | const cleanMessage = this.stripModulePath(message.trim());
110 | return {
111 | timestamp,
112 | level: level as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR',
113 | target: target.trim(),
114 | span_root: {
115 | name: target.trim(),
116 | fields: []
117 | },
118 | message: cleanMessage,
119 | rawText: line,
120 | };
121 | }
122 |
123 | return null;
124 | }
125 |
126 | /**
127 | * Strips module paths from the beginning of a message
128 | * A module path is something like "crate::module::submodule:" at the start of a message
129 | */
130 | private stripModulePath(message: string): string {
131 | // Only match module paths at the start that are followed by a colon and whitespace
132 | // Don't match if it's part of an event name or field value
133 | const modulePathMatch = message.match(/^([a-zA-Z0-9_]+(?:::[a-zA-Z0-9_]+)+):\s+/);
134 | if (modulePathMatch) {
135 | // Return everything after the module path and colon, trimmed
136 | return message.substring(modulePathMatch[0].length).trim();
137 | }
138 | return message;
139 | }
140 |
141 | private parseSpanChain(spanChain: string): RustSpan {
142 | if (!spanChain) {
143 | return {
144 | name: 'root',
145 | fields: []
146 | };
147 | }
148 |
149 | // Split the span chain into individual spans
150 | const spans = spanChain.split(':').map(span => span.trim());
151 |
152 | // Parse each span into a name and fields
153 | const parsedSpans = spans.map(span => {
154 | const nameMatch = span.match(/^([^{]+)(?:\{([^}]+)\})?$/);
155 | if (!nameMatch) {
156 | // Handle spans without fields
157 | return { name: span, fields: [] };
158 | }
159 |
160 | const [_, name, fieldsStr] = nameMatch;
161 | // Use the dedicated parseFields method
162 | const fields = this.parseFields(fieldsStr || '');
163 |
164 | return { name: name.trim(), fields };
165 | });
166 |
167 | // Build the span hierarchy
168 | let rootSpan: RustSpan = {
169 | name: parsedSpans[0].name,
170 | fields: parsedSpans[0].fields
171 | };
172 |
173 | let currentSpan = rootSpan;
174 | for (let i = 1; i < parsedSpans.length; i++) {
175 | currentSpan.child = {
176 | name: parsedSpans[i].name,
177 | fields: parsedSpans[i].fields
178 | };
179 | currentSpan = currentSpan.child;
180 | }
181 |
182 | return rootSpan;
183 | }
184 |
185 | private parseFields(fieldsString: string): RustSpanField[] {
186 | if (!fieldsString || fieldsString.trim() === '') {
187 | return [];
188 | }
189 |
190 | // Split on spaces that are followed by a word and equals sign, but not inside square brackets
191 | const fields = fieldsString.split(/\s+(?=[^[\]]*(?:\[|$))(?=\w+=)/);
192 |
193 | return fields.map(field => {
194 | const [key, ...valueParts] = field.split('=');
195 | if (!key || valueParts.length === 0) {
196 | return null;
197 | }
198 |
199 | let value = valueParts.join('='); // Rejoin in case value contained =
200 |
201 | // Remove surrounding quotes if present
202 | value = value.replace(/^["'](.*)["']$/, '$1');
203 |
204 | return {
205 | name: key,
206 | value: value
207 | };
208 | }).filter((field): field is RustSpanField => field !== null);
209 | }
210 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2025 Arthur Gousset and Priyank Chodisetti
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/cli/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2025 Arthur Gousset and Priyank Chodisetti
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/vscode-extension/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2025 Arthur Gousset and Priyank Chodisetti
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/vscode-extension/src/variableExplorer.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 | import { RustLogEntry } from './logExplorer';
3 | import { ClaudeService } from './claudeService';
4 | import { VariableDecorator } from './variableDecorator';
5 |
6 | /**
7 | * Tree view entry representing a variable
8 | */
9 | export class VariableItem extends vscode.TreeItem {
10 | public buttons?: {
11 | iconPath: vscode.ThemeIcon;
12 | tooltip: string;
13 | command?: string | {
14 | command: string;
15 | title: string;
16 | arguments?: any[];
17 | };
18 | }[];
19 |
20 | constructor(
21 | public readonly label: string,
22 | public readonly itemValue: any,
23 | public readonly itemType: string = 'variable',
24 | collapsibleState: vscode.TreeItemCollapsibleState
25 | ) {
26 | super(label, collapsibleState);
27 |
28 | // Set contextValue for context menu and when clause
29 | this.contextValue = itemType;
30 |
31 | // Format the description based on the value type
32 | this.description = this.formatValueForDisplay(itemValue);
33 |
34 | // Set an appropriate icon based on the type
35 | this.iconPath = this.getIconForType(itemValue);
36 |
37 | // Add ability to show variable in editor and also allow copy
38 | if (itemType === 'property' || itemType === 'variable' || itemType === 'arrayItem') {
39 | this.command = {
40 | command: 'traceback.showVariableInEditor',
41 | title: 'Show Variable in Editor',
42 | arguments: [label, itemValue],
43 | };
44 | } else {
45 | // Default to copy for non-variable items
46 | this.command = {
47 | command: 'traceback.copyVariableValue',
48 | title: 'Copy Value',
49 | arguments: [itemValue],
50 | };
51 | }
52 |
53 | // Set tooltip with extended information
54 | this.tooltip = `${label}: ${this.description}`;
55 |
56 | // Set a specific contextValue for items that can be inspected (for context menu)
57 | if (itemType === 'header' || itemType === 'property' || itemType === 'variable' ||
58 | itemType === 'arrayItem' || itemType === 'section') {
59 | this.contextValue = `${itemType}-inspectable`;
60 |
61 | // Add eye button for VS Code 1.74.0+ (buttons property is available)
62 | this.buttons = [
63 | {
64 | iconPath: new vscode.ThemeIcon('eye'),
65 | tooltip: 'Inspect Value',
66 | command: 'traceback.inspectVariableFromContext'
67 | }
68 | ];
69 | }
70 | }
71 |
72 | /**
73 | * Format a value nicely for display in the tree view
74 | */
75 | private formatValueForDisplay(value: any): string {
76 | if (value === null) return 'null';
77 | if (value === undefined) return 'undefined';
78 |
79 | const type = typeof value;
80 |
81 | if (type === 'string') {
82 | if (value.length > 50) {
83 | return `"${value.substring(0, 47)}..."`;
84 | }
85 | return `"${value}"`;
86 | }
87 |
88 | if (type === 'object') {
89 | if (Array.isArray(value)) {
90 | return `Array(${value.length})`;
91 | }
92 | return value.constructor.name;
93 | }
94 |
95 | return String(value);
96 | }
97 |
98 | /**
99 | * Get an appropriate icon for the value type
100 | */
101 | private getIconForType(value: any): vscode.ThemeIcon {
102 | if (value === null || value === undefined) {
103 | return new vscode.ThemeIcon('circle-outline');
104 | }
105 |
106 | const type = typeof value;
107 |
108 | switch (type) {
109 | case 'string':
110 | return new vscode.ThemeIcon('symbol-string');
111 | case 'number':
112 | return new vscode.ThemeIcon('symbol-number');
113 | case 'boolean':
114 | return new vscode.ThemeIcon('symbol-boolean');
115 | case 'object':
116 | if (Array.isArray(value)) {
117 | return new vscode.ThemeIcon('symbol-array');
118 | }
119 | return new vscode.ThemeIcon('symbol-object');
120 | case 'function':
121 | return new vscode.ThemeIcon('symbol-method');
122 | default:
123 | return new vscode.ThemeIcon('symbol-property');
124 | }
125 | }
126 | }
127 |
128 | /**
129 | * Tree data provider for the variable explorer
130 | */
131 | export class VariableExplorerProvider implements vscode.TreeDataProvider {
132 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter();
133 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event;
134 |
135 | private currentLog: RustLogEntry | undefined;
136 | private isAnalyzing: boolean = false;
137 | private claudeService: ClaudeService = ClaudeService.getInstance();
138 | private variableDecorator: VariableDecorator | undefined;
139 |
140 | constructor(private context: vscode.ExtensionContext) {}
141 |
142 | public setVariableDecorator(decorator: VariableDecorator): void {
143 | this.variableDecorator = decorator;
144 | }
145 |
146 | public setLog(log: RustLogEntry | undefined, isAnalyzing: boolean = false): void {
147 | this.currentLog = log;
148 | this.isAnalyzing = isAnalyzing;
149 | this._onDidChangeTreeData.fire();
150 | }
151 |
152 | public getLog(): RustLogEntry | undefined {
153 | return this.currentLog;
154 | }
155 |
156 | /**
157 | * Get the TreeItem for a given element
158 | */
159 | getTreeItem(element: VariableItem): VariableItem {
160 | return element;
161 | }
162 |
163 | /**
164 | * Get children for a given element
165 | */
166 | getChildren(element?: VariableItem): Thenable {
167 | if (!this.currentLog) {
168 | // No log selected, show a placeholder
169 | return Promise.resolve([
170 | new VariableItem(
171 | 'No log selected',
172 | 'Click on a log in the Log Explorer view',
173 | 'message',
174 | vscode.TreeItemCollapsibleState.None
175 | )
176 | ]);
177 | }
178 |
179 | if (this.isAnalyzing) {
180 | return Promise.resolve([
181 | new VariableItem(
182 | 'Analyzing variables...',
183 | 'Please wait while we analyze the log',
184 | 'message',
185 | vscode.TreeItemCollapsibleState.None
186 | )
187 | ]);
188 | }
189 |
190 | if (!element) {
191 | // Root level - show log sections
192 | const items: VariableItem[] = [];
193 |
194 | // Add a header showing the log message
195 | const headerMessage = this.currentLog.message;
196 |
197 | // For the header, we'll use the entire log object for inspection
198 | const headerItem = new VariableItem(
199 | headerMessage,
200 | this.currentLog,
201 | 'header',
202 | vscode.TreeItemCollapsibleState.None
203 | );
204 |
205 | // Override description to show timestamp
206 | headerItem.description = `Log from ${new Date(this.currentLog.timestamp).toLocaleString()}`;
207 | items.push(headerItem);
208 |
209 | // Add Claude's inferred variables if available
210 | if (this.currentLog.claudeAnalysis?.variables) {
211 | items.push(new VariableItem(
212 | 'Inferred Variables',
213 | this.currentLog.claudeAnalysis.variables,
214 | 'section',
215 | vscode.TreeItemCollapsibleState.Expanded
216 | ));
217 | }
218 |
219 | // Add span fields section
220 | if (this.currentLog.span_root.fields.length > 0) {
221 | items.push(new VariableItem(
222 | 'Fields',
223 | this.currentLog.span_root.fields,
224 | 'section',
225 | vscode.TreeItemCollapsibleState.Expanded
226 | ));
227 | }
228 |
229 | // Basic log metadata
230 | const metadata: Record = {
231 | severity: this.currentLog.level,
232 | level: this.currentLog.level,
233 | timestamp: this.currentLog.timestamp
234 | };
235 |
236 | items.push(new VariableItem(
237 | 'Metadata',
238 | metadata,
239 | 'section',
240 | vscode.TreeItemCollapsibleState.Collapsed
241 | ));
242 |
243 | return Promise.resolve(items);
244 | } else {
245 | // Child elements - handle different types of values
246 | const value = element.itemValue;
247 |
248 | if (value === null || value === undefined) {
249 | return Promise.resolve([]);
250 | }
251 |
252 | if (typeof value !== 'object') {
253 | return Promise.resolve([]);
254 | }
255 |
256 | // Handle arrays
257 | if (Array.isArray(value)) {
258 | return Promise.resolve(
259 | value.map((item, index) => {
260 | const itemValue = item;
261 | const isExpandable = typeof itemValue === 'object' && itemValue !== null;
262 |
263 | return new VariableItem(
264 | `[${index}]`,
265 | itemValue,
266 | 'arrayItem',
267 | isExpandable
268 | ? vscode.TreeItemCollapsibleState.Collapsed
269 | : vscode.TreeItemCollapsibleState.None
270 | );
271 | })
272 | );
273 | }
274 |
275 | // Handle objects
276 | return Promise.resolve(
277 | Object.entries(value)
278 | .filter(([key, val]) => key !== 'message' || element.itemType !== 'Fields')
279 | .map(([key, val]) => {
280 | const isExpandable = typeof val === 'object' && val !== null;
281 |
282 | return new VariableItem(
283 | key,
284 | val,
285 | 'property',
286 | isExpandable
287 | ? vscode.TreeItemCollapsibleState.Collapsed
288 | : vscode.TreeItemCollapsibleState.None
289 | );
290 | })
291 | );
292 | }
293 | }
294 | }
295 |
296 | /**
297 | * Register the variable explorer view and related commands
298 | */
299 | export function registerVariableExplorer(context: vscode.ExtensionContext): VariableExplorerProvider {
300 | // Create the provider
301 | const variableExplorerProvider = new VariableExplorerProvider(context);
302 |
303 | // Register the tree view with the updated ID
304 | const treeView = vscode.window.createTreeView('logVariableExplorer', {
305 | treeDataProvider: variableExplorerProvider,
306 | showCollapseAll: true
307 | });
308 |
309 | // Since onDidClickTreeItem isn't available in VS Code 1.74, we'll rely on:
310 | // 1. The context menu for eye icon in the tree
311 | // 2. Creating a custom event handler for TreeView selection changes
312 |
313 | treeView.onDidChangeSelection((e) => {
314 | // Only handle single selections
315 | if (e.selection.length === 1) {
316 | const item = e.selection[0];
317 |
318 | // If the item has the inspect context value, consider opening the inspect UI
319 | // Note: this will be triggered on any tree item selection, which may not be ideal
320 | // We'll keep this commented out to avoid unexpected behavior
321 | //
322 | // if (item.contextValue && item.contextValue.endsWith('-inspectable')) {
323 | // vscode.commands.executeCommand('traceback.inspectVariableValue', item.label, item.itemValue);
324 | // }
325 | }
326 | });
327 |
328 | // Register a command to copy variable values
329 | const copyValueCommand = vscode.commands.registerCommand(
330 | 'traceback.copyVariableValue',
331 | (value: any) => {
332 | const stringValue = typeof value === 'object'
333 | ? JSON.stringify(value, null, 2)
334 | : String(value);
335 |
336 | vscode.env.clipboard.writeText(stringValue);
337 | vscode.window.showInformationMessage('Value copied to clipboard');
338 | }
339 | );
340 |
341 | // Register a command to inspect variable value
342 | const inspectVariableCommand = vscode.commands.registerCommand(
343 | 'traceback.inspectVariableValue',
344 | // Handle both direct args and context cases
345 | async (variableNameArg?: string, variableValueArg?: any) => {
346 | let variableName = variableNameArg;
347 | let variableValue = variableValueArg;
348 |
349 | // If no arguments provided or they're undefined, try to find variable from context
350 | if (variableName === undefined || variableValue === undefined) {
351 | console.log('Finding variable from context...');
352 |
353 | try {
354 | // Get the currently focused item from the tree view
355 | const selection = treeView.selection;
356 | if (selection.length > 0) {
357 | const selectedItem = selection[0] as VariableItem;
358 | variableName = selectedItem.label;
359 | variableValue = selectedItem.itemValue;
360 | console.log('Using selected item:', { variableName, variableValue });
361 | } else {
362 | // No selection - try to get active tree item by querying visible items
363 | // This is necessary because button clicks might not select the item
364 | const msg = 'Cannot inspect: Please select a variable first.';
365 | vscode.window.showInformationMessage(msg);
366 | return;
367 | }
368 | } catch (err) {
369 | console.error('Error finding variable context:', err);
370 | vscode.window.showErrorMessage('Error inspecting variable: ' + String(err));
371 | return;
372 | }
373 | }
374 |
375 | // Format the value for display with special handling for undefined
376 | let stringValue = 'undefined';
377 |
378 | if (variableValue !== undefined) {
379 | stringValue = typeof variableValue === 'object'
380 | ? JSON.stringify(variableValue, null, 2)
381 | : String(variableValue);
382 | }
383 |
384 | // For small values, show in an input box
385 | if (stringValue.length < 1000) {
386 | const inputBox = vscode.window.createInputBox();
387 |
388 | // Create a truncated title (limit to 30 chars)
389 | const maxTitleLength = 30;
390 | const truncatedName = variableName.length > maxTitleLength
391 | ? variableName.substring(0, maxTitleLength) + '...'
392 | : variableName;
393 |
394 | inputBox.title = `Inspect: ${truncatedName}`;
395 | inputBox.value = stringValue;
396 | inputBox.password = false;
397 | inputBox.ignoreFocusOut = true;
398 | inputBox.enabled = false; // Make it read-only
399 |
400 | // Show the input box
401 | inputBox.show();
402 |
403 | // Hide it when pressing escape
404 | inputBox.onDidHide(() => inputBox.dispose());
405 | } else {
406 | // For larger values, create a temporary webview panel
407 | // that can be closed with Escape and allows scrolling
408 |
409 | // Create a truncated title (limit to 30 chars)
410 | const maxTitleLength = 30;
411 | const truncatedName = variableName.length > maxTitleLength
412 | ? variableName.substring(0, maxTitleLength) + '...'
413 | : variableName;
414 |
415 | const panel = vscode.window.createWebviewPanel(
416 | 'variableInspect',
417 | `Inspect: ${truncatedName}`,
418 | vscode.ViewColumn.Active,
419 | {
420 | enableScripts: false,
421 | retainContextWhenHidden: false
422 | }
423 | );
424 |
425 | // Style the webview content for readability with scrolling
426 | panel.webview.html = `
427 |
428 |
429 |
430 |
431 |
432 | Variable Inspector
433 |
458 |
459 |
460 |
${escapeHtml(variableName)}
461 |
${escapeHtml(stringValue)}
462 |
463 |
464 | `;
465 | }
466 | }
467 | );
468 |
469 | // Helper function to escape HTML characters
470 | function escapeHtml(unsafe: string): string {
471 | return unsafe
472 | .replace(/&/g, "&")
473 | .replace(//g, ">")
475 | .replace(/"/g, """)
476 | .replace(/'/g, "'");
477 | }
478 |
479 | // Register a command to inspect variable from context menu or button click
480 | const inspectVariableFromContextCommand = vscode.commands.registerCommand(
481 | 'traceback.inspectVariableFromContext',
482 | (contextItem?: VariableItem) => {
483 | // Convert the provided context to VariableItem type if possible
484 | if (contextItem && contextItem.label && contextItem.itemValue !== undefined) {
485 | vscode.commands.executeCommand('traceback.inspectVariableValue', contextItem.label, contextItem.itemValue);
486 | return;
487 | }
488 |
489 | // If no item provided directly, use the currently selected item
490 | // This handles button clicks where item context isn't passed
491 | try {
492 | if (treeView.selection.length > 0) {
493 | const selectedItem = treeView.selection[0] as VariableItem;
494 | if (selectedItem && selectedItem.label && selectedItem.itemValue !== undefined) {
495 | vscode.commands.executeCommand('traceback.inspectVariableValue',
496 | selectedItem.label,
497 | selectedItem.itemValue);
498 | return;
499 | }
500 | }
501 | // If we got here, we couldn't find a valid item to inspect
502 | vscode.window.showInformationMessage('Please select a variable to inspect');
503 | } catch (error) {
504 | console.error('Error inspecting variable:', error);
505 | vscode.window.showErrorMessage('Error inspecting variable: ' + String(error));
506 | }
507 | }
508 | );
509 |
510 | // Add to the extension context
511 | context.subscriptions.push(
512 | treeView,
513 | copyValueCommand,
514 | inspectVariableCommand,
515 | inspectVariableFromContextCommand
516 | );
517 |
518 | return variableExplorerProvider;
519 | }
--------------------------------------------------------------------------------
/cli/tracebackapp/tools/analysis_tools.py:
--------------------------------------------------------------------------------
1 | """Tools for analyzing logs, stack traces, and code locations."""
2 |
3 | import os
4 | import logging
5 | import subprocess
6 | from dataclasses import dataclass
7 | from typing import List, Optional, Callable, Any, Dict, Set
8 | from .claude_client import ClaudeClient
9 |
10 | # Configure logging
11 | log_dir = os.path.expanduser("~/.traceback")
12 | os.makedirs(log_dir, exist_ok=True) # Ensure .traceback directory exists
13 | log_file_path = os.path.join(log_dir, "claude_api.log")
14 |
15 | # Configure our logger
16 | logger = logging.getLogger("analysis_tools")
17 | logger.setLevel(logging.DEBUG)
18 |
19 | # Create file handler if not already added
20 | if not logger.handlers:
21 | file_handler = logging.FileHandler(log_file_path)
22 | file_handler.setLevel(logging.DEBUG)
23 |
24 | # Create formatter - simplified format focusing on key info
25 | formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
26 | file_handler.setFormatter(formatter)
27 |
28 | # Add handler to logger
29 | logger.addHandler(file_handler)
30 |
31 | # Prevent propagation to avoid duplicate logs
32 | logger.propagate = False
33 |
34 | @dataclass
35 | class CodeLocation:
36 | file_path: str
37 | line_number: int
38 | function_name: Optional[str] = None
39 |
40 | def __str__(self) -> str:
41 | return f"{self.file_path}:{self.line_number}"
42 |
43 | @dataclass
44 | class StackTraceEntry:
45 | code_location: CodeLocation
46 | context: Optional[str] = None
47 |
48 | @dataclass
49 | class AnalysisContext:
50 | """Analysis context for a debugging session."""
51 | current_findings: Dict[str, Any] # Findings so far
52 | current_page: int = 0 # Current page number (0-based internally)
53 | total_pages: int = 0 # Total number of pages
54 | page_size: int = 50000 # Characters per page
55 | overlap_size: int = 5000 # Characters of overlap between pages
56 | all_logs: str = "" # Complete log content
57 | current_page_content: Optional[str] = None # Content of the current page being analyzed
58 | iterations: int = 0
59 | MAX_ITERATIONS = 50
60 |
61 | def __init__(self, initial_input: str):
62 | self.current_findings = {
63 | "searched_patterns": set(),
64 | "fetched_files": set(), # This will store individual file paths, not sets of files
65 | "fetched_logs_pages": set([1]),
66 | "fetched_code": set(),
67 | "currentAnalysis": ""
68 | }
69 | self.all_logs = initial_input
70 | # Calculate total pages based on content length and overlap
71 | self.total_pages = max(1, (len(initial_input) + self.page_size - 1) // (self.page_size - self.overlap_size))
72 | self.current_page = 0 # Start at first page (0-based internally)
73 | self.current_page_content = "Logs: \n Page 1 of " + str(self.total_pages) + ":\n" + self.get_current_page()
74 | logger.info(f"Total pages of Logs: {self.total_pages}")
75 |
76 | def get_current_page(self) -> str:
77 | """Get the current page of logs with overlap."""
78 | # Calculate start and end positions based on 0-based page number
79 | start = max(0, self.current_page * (self.page_size - self.overlap_size))
80 | end = min(len(self.all_logs), start + self.page_size)
81 |
82 | # If this is not the first page, include overlap from previous page
83 | if self.current_page > 0:
84 | start = max(0, start - self.overlap_size)
85 |
86 | return self.all_logs[start:end]
87 |
88 | def advance_page(self) -> bool:
89 | """
90 | Advance to next page. Returns False if no more pages.
91 | Note: Uses 0-based page numbers internally.
92 | """
93 | if self.current_page + 1 >= self.total_pages:
94 | return False
95 | self.current_page += 1
96 | return True
97 |
98 | def get_current_page_number(self) -> int:
99 | """Get the current page number in 1-based format for external use."""
100 | return self.current_page + 1
101 |
102 | def get_total_pages(self) -> int:
103 | """Get the total number of pages."""
104 | return self.total_pages
105 |
106 | def mark_page_analyzed(self, page_number: int) -> None:
107 | """Mark a page as analyzed (using 1-based page numbers)."""
108 | self.analyzed_pages.add(page_number)
109 |
110 | def is_page_analyzed(self, page_number: int) -> bool:
111 | """Check if a page has been analyzed (using 1-based page numbers)."""
112 | return page_number in self.analyzed_pages
113 |
114 | def get_analyzed_pages(self) -> List[int]:
115 | """Get list of analyzed pages in sorted order (1-based)."""
116 | return sorted(list(self.analyzed_pages))
117 |
118 | class Analyzer:
119 | """Analyzer for debugging issues using Claude."""
120 |
121 | def __init__(self, workspace_root: Optional[str] = None):
122 | """Initialize the analyzer."""
123 | self.workspace_root = workspace_root or os.getcwd()
124 | logger.info(f"Initialized analyzer with workspace root: {self.workspace_root}")
125 |
126 | # Load API key from ~/.traceback/api_key
127 | api_key = None
128 | api_key_file = os.path.expanduser("~/.traceback/api_key")
129 | try:
130 | if os.path.exists(api_key_file):
131 | with open(api_key_file, 'r') as f:
132 | api_key = f.read().strip()
133 | except Exception:
134 | pass
135 |
136 | self.claude = ClaudeClient(api_key=api_key)
137 | self.display_callback: Optional[Callable[[str], None]] = None
138 | self.context = None
139 |
140 | def analyze(self, initial_input: str, display_callback: Optional[Callable[[str], None]]) -> None:
141 | """
142 | Analyze input using Claude and execute suggested tools.
143 |
144 | Args:
145 | initial_input: Initial input to analyze (logs, error message, etc)
146 | display_callback: Optional callback to display progress
147 | """
148 |
149 | # Initialize context if not provided
150 | if not self.context:
151 | self.context = AnalysisContext(initial_input)
152 |
153 | # Store display callback
154 | self.display_callback = display_callback
155 |
156 | # Prevent infinite recursion
157 | if self.context.iterations >= self.context.MAX_ITERATIONS:
158 | logger.warning(f"Analysis stopped: Maximum iterations ({self.context.MAX_ITERATIONS}) reached")
159 | if display_callback:
160 | display_callback(f"Analysis stopped: Maximum iterations ({self.context.MAX_ITERATIONS}) reached")
161 | return
162 |
163 | # Log the current state
164 | logger.info(f"=== Starting new LLM analysis ===")
165 | logger.info(f"Input length: {len(self.context.current_page_content)}")
166 | logger.info(f"Current findings: {self.context.current_findings}")
167 |
168 | response = self.claude.analyze_error(self.context.current_page_content, self.context.current_findings)
169 |
170 | if not response or 'tool' not in response:
171 | logger.error("Invalid response from Claude")
172 | return
173 |
174 | tool_name = response.get('tool')
175 | tool_params = response.get('params', {})
176 | analysis = response.get('analysis', '')
177 |
178 | if display_callback and analysis:
179 | display_callback(analysis)
180 | if tool_params.get('currentAnalysis') and display_callback:
181 | display_callback(f"Current analysis: {tool_params.get('currentAnalysis')}")
182 | self.context.current_findings['currentAnalysis'] = tool_params.get('currentAnalysis')
183 |
184 | try:
185 | # Execute the suggested tool
186 | if tool_name == 'fetch_files':
187 | search_patterns = tool_params.get('search_patterns', [])
188 | if search_patterns:
189 | self._fetch_files(self.context, search_patterns)
190 | self.context.iterations += 1
191 | display_callback(f"Iteration {self.context.iterations}: Sending fetched files to LLM")
192 | self.analyze(self.context.current_page_content, display_callback)
193 | return
194 | elif tool_name == 'fetch_logs':
195 | page_number = tool_params.get('page_number')
196 |
197 | if page_number is not None and page_number in self.context.current_findings['fetched_logs_pages']:
198 | logger.warning(f"Page {page_number} has already been analyzed, skipping")
199 | display_callback(f"Page {page_number} has already been analyzed, skipping")
200 |
201 | if self.context.advance_page():
202 | self.context.current_page_content = "Logs: \n Page " + str(self.context.get_current_page_number()) + " of " + str(self.context.get_total_pages()) + ":\n" + self.context.get_current_page()
203 | self.context.current_findings['fetched_logs_pages'].add(page_number)
204 | display_callback(f"Sending next page to LLM")
205 | self.analyze(self.context.current_page_content, display_callback)
206 | else:
207 | self.context.current_page_content = "No more pages to analyze"
208 | display_callback(f"No more pages to analyze. Letting LLM know")
209 | self.analyze(self.context.current_page_content, display_callback)
210 |
211 | return
212 |
213 | elif tool_name == 'fetch_code':
214 | filename = tool_params.get('filename')
215 | line_number = tool_params.get('line_number')
216 | if filename and line_number:
217 | self._fetch_code(self.context, filename, line_number)
218 | self.context.iterations += 1
219 | display_callback(f"Iteration {self.context.iterations}: Sending fetched code to LLM")
220 | self.analyze(self.context.current_page_content, display_callback)
221 | return
222 |
223 | elif tool_name == 'show_root_cause':
224 | root_cause = tool_params.get('root_cause', '')
225 | if root_cause and display_callback:
226 | display_callback(f"\nRoot Cause Analysis:\n{root_cause}")
227 | return
228 |
229 | else:
230 | logger.warning(f"Unknown tool: {tool_name}")
231 | return
232 |
233 | except Exception as e:
234 | logger.error(f"Error executing tool {tool_name}: {str(e)}")
235 | display_callback(f"Error executing tool {tool_name}: {str(e)}")
236 |
237 | def _get_gitignore_dirs(self) -> List[str]:
238 | """Get directory patterns from .gitignore file."""
239 | gitignore_path = os.path.join(self.workspace_root, '.gitignore')
240 | dirs_to_exclude = set()
241 |
242 | try:
243 | if os.path.exists(gitignore_path):
244 | with open(gitignore_path, 'r') as f:
245 | for line in f:
246 | line = line.strip()
247 | # Skip comments and empty lines
248 | if not line or line.startswith('#'):
249 | continue
250 | # Look for directory patterns (ending with /)
251 | if line.endswith('/'):
252 | dirs_to_exclude.add(line.rstrip('/'))
253 | # Also add common build/binary directories if not already specified
254 | dirs_to_exclude.update(['target', 'node_modules', '.git', 'dist', 'build'])
255 | logger.info(f"Found directories to exclude: {sorted(dirs_to_exclude)}")
256 | else:
257 | logger.info("No .gitignore file found, using default exclusions")
258 | dirs_to_exclude = {'target', 'node_modules', '.git', 'dist', 'build'}
259 | except Exception as e:
260 | logger.error(f"Error reading .gitignore: {str(e)}")
261 | dirs_to_exclude = {'target', 'node_modules', '.git', 'dist', 'build'}
262 |
263 | return sorted(list(dirs_to_exclude))
264 |
265 | def _fetch_files(self, context: AnalysisContext, search_patterns: List[str]) -> None:
266 | """
267 | Fetch files matching the given search patterns.
268 |
269 | Args:
270 | context: Analysis context
271 | search_patterns: List of strings to search for in files
272 | """
273 | import time
274 | start_time = time.time()
275 |
276 | logger.info("=" * 50)
277 | logger.info("Starting file search operation")
278 | logger.info("=" * 50)
279 | logger.info(f"Search patterns ({len(search_patterns)}): {search_patterns}")
280 | logger.info(f"Working directory: {os.getcwd()}")
281 | logger.info(f"Workspace root: {self.workspace_root}")
282 |
283 | if self.display_callback:
284 | self.display_callback(f"Searching for files matching patterns: {', '.join(search_patterns)}")
285 |
286 | found_files = set()
287 | patterns_matched = {pattern: 0 for pattern in search_patterns}
288 |
289 | # Get directories to exclude from .gitignore
290 | exclude_dirs = self._get_gitignore_dirs()
291 | exclude_args = []
292 | for dir_name in exclude_dirs:
293 | exclude_args.extend(['--exclude-dir', dir_name])
294 |
295 | # Search for each pattern
296 | for pattern in search_patterns:
297 | pattern_start_time = time.time()
298 | logger.info("-" * 40)
299 | logger.info(f"Processing pattern: {pattern}")
300 |
301 | try:
302 | # Use grep with recursive search and exclusions
303 | grep_cmd = ['grep', '-r', '-l', *exclude_args, pattern]
304 | if self.workspace_root:
305 | grep_cmd.append(self.workspace_root)
306 | else:
307 | grep_cmd.append('.')
308 |
309 | logger.info(f"Running grep command: {' '.join(grep_cmd)}")
310 | grep_result = subprocess.run(grep_cmd, capture_output=True, text=True)
311 |
312 | # grep returns 0 if matches found, 1 if no matches (not an error)
313 | if grep_result.returncode not in [0, 1]:
314 | error = f"Grep command failed: {grep_result.stderr}"
315 | logger.error(error)
316 | continue
317 |
318 | # Process matches
319 | matches = grep_result.stdout.splitlines()
320 | for file in matches:
321 | found_files.add(file)
322 | patterns_matched[pattern] += 1
323 | logger.info(f"Match found: {file}")
324 |
325 | pattern_duration = time.time() - pattern_start_time
326 | logger.info(f"Pattern '{pattern}' completed in {pattern_duration:.2f}s")
327 | logger.info(f"Found {patterns_matched[pattern]} matches for this pattern")
328 |
329 | except Exception as e:
330 | error = f"Error searching for pattern '{pattern}': {str(e)}"
331 | logger.error(error)
332 | if self.display_callback:
333 | self.display_callback(error)
334 |
335 | total_duration = time.time() - start_time
336 |
337 | # Log final statistics
338 | logger.info("=" * 50)
339 | logger.info("Search operation completed")
340 | logger.info(f"Total time: {total_duration:.2f}s")
341 | logger.info(f"Total unique files with matches: {len(found_files)}")
342 | logger.info("Pattern matches:")
343 | for pattern, count in patterns_matched.items():
344 | logger.info(f" - '{pattern}': {count} files")
345 | logger.info("=" * 50)
346 |
347 | # Add finding with results
348 | if found_files:
349 | # Update the set with individual file paths instead of adding the set itself
350 | context.current_findings['fetched_files'].update(found_files)
351 | # Convert search patterns list to tuple to make it hashable
352 | context.current_findings['searched_patterns'].update(search_patterns)
353 | context.current_page_content = f"Found {len(found_files)} files matching patterns: {', '.join(search_patterns)}"
354 | context.current_page_content += f"\n\nList of files:\n{'\n'.join(sorted(found_files))}"
355 | logger.info(f"Found {len(found_files)} files matching patterns")
356 | if self.display_callback:
357 | self.display_callback(f"Found {len(found_files)} files matching patterns")
358 | else:
359 | context.current_page_content = f"No files found matching patterns: {', '.join(search_patterns)}"
360 |
361 | logger.info("No files found matching patterns")
362 | if self.display_callback:
363 | self.display_callback("No files found matching patterns")
364 |
365 | def _fetch_code(self, context: AnalysisContext, filename: str, line_number: int) -> None:
366 | """
367 | Fetch code based on file and line number hints in the context.
368 |
369 | Args:
370 | context: Analysis context
371 | filename: Name of the file to fetch
372 | line_number: Line number to focus on
373 | """
374 | logger.info(f"=== Starting code fetch ===")
375 | logger.info(f"Code context: {filename} at line {line_number}")
376 |
377 | if self.display_callback:
378 | self.display_callback(f"Fetching code related to: {filename} at line {line_number}")
379 |
380 | try:
381 | # Get possible local paths
382 | possible_paths = self._translate_path(filename)
383 | found_path = None
384 |
385 | # Try each possible path
386 | for path in possible_paths:
387 | if os.path.exists(path):
388 | found_path = path
389 | logger.info(f"Found equivalent file: {path}")
390 | break
391 |
392 | if not found_path:
393 | error_msg = f"File not found in any of the possible locations: {possible_paths}"
394 | logger.warning(error_msg)
395 | raise FileNotFoundError(error_msg)
396 |
397 | # Read the file
398 | with open(found_path, 'r') as f:
399 | lines = f.readlines()
400 |
401 | # Get code context (20 lines before and after)
402 | context_lines = 20
403 | start = max(0, line_number - context_lines)
404 | end = min(len(lines), line_number + context_lines + 1)
405 | code = ''.join(lines[start:end])
406 |
407 | self.context.current_findings['fetched_code'].add((filename, line_number))
408 | self.context.current_page_content = f"Code: \n File: {filename} \n Line: {line_number}"
409 | self.context.current_page_content += f"\n\nCode:\n{code}"
410 |
411 | logger.info(f"Successfully fetched code from {found_path} around line {line_number}")
412 | if self.display_callback:
413 | self.display_callback(f"Fetched code from {filename} around line {line_number}")
414 |
415 | except Exception as e:
416 | error = f"Error fetching code: {str(e)}"
417 | logger.error(error)
418 |
419 | if self.display_callback:
420 | self.display_callback(error)
421 |
422 | def _translate_path(self, filename: str) -> List[str]:
423 | """
424 | Translate a filename to possible local paths.
425 |
426 | Args:
427 | filename: The filename to translate
428 |
429 | Returns:
430 | List of possible local paths
431 | """
432 | possible_paths = []
433 |
434 | # Try direct path
435 | if os.path.isabs(filename):
436 | possible_paths.append(filename)
437 |
438 | # Try relative to workspace root
439 | workspace_path = os.path.join(self.workspace_root, filename)
440 | possible_paths.append(workspace_path)
441 |
442 | # Try without leading path components
443 | base_name = os.path.basename(filename)
444 | possible_paths.append(os.path.join(self.workspace_root, base_name))
445 |
446 | return possible_paths
--------------------------------------------------------------------------------
/cli/tracebackapp/tools/claude_client.py:
--------------------------------------------------------------------------------
1 | """Claude API client for interacting with Claude 3.7 Sonnet."""
2 |
3 | import os
4 | import json
5 | import time
6 | from typing import Dict, Any, List, Optional, Union
7 | from dataclasses import dataclass
8 | import logging
9 | from anthropic import Anthropic
10 |
11 | # Configure logging
12 | log_dir = os.path.expanduser("~/.traceback")
13 | os.makedirs(log_dir, exist_ok=True) # Ensure .traceback directory exists
14 | log_file_path = os.path.join(log_dir, "claude_api.log")
15 |
16 | # Configure our logger
17 | logger = logging.getLogger("claude_client")
18 | logger.setLevel(logging.DEBUG)
19 |
20 | # Create file handler
21 | file_handler = logging.FileHandler(log_file_path)
22 | file_handler.setLevel(logging.DEBUG)
23 |
24 | # Create formatter - simplified format focusing on key info
25 | formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
26 | file_handler.setFormatter(formatter)
27 |
28 | # Add handler to logger
29 | logger.addHandler(file_handler)
30 | # Prevent propagation to avoid duplicate logs
31 | logger.propagate = False
32 |
33 | @dataclass
34 | class RateLimitState:
35 | """Track rate limit state."""
36 | last_request_time: float = 0.0 # Last request timestamp
37 | requests_remaining: int = 50 # Default to tier 1 limits
38 | tokens_remaining: int = 20000
39 | reset_time: Optional[float] = None
40 |
41 | def update_from_headers(self, headers: Dict[str, str]) -> None:
42 | """Update state from response headers."""
43 | if 'anthropic-ratelimit-requests-remaining' in headers:
44 | self.requests_remaining = int(headers['anthropic-ratelimit-requests-remaining'])
45 | logger.info(f"Rate limit update - Requests remaining: {self.requests_remaining}")
46 |
47 | if 'anthropic-ratelimit-tokens-remaining' in headers:
48 | self.tokens_remaining = int(headers['anthropic-ratelimit-tokens-remaining'])
49 | logger.info(f"Rate limit update - Tokens remaining: {self.tokens_remaining}")
50 |
51 | if 'anthropic-ratelimit-requests-reset' in headers:
52 | from datetime import datetime
53 | reset_time = datetime.fromisoformat(headers['anthropic-ratelimit-requests-reset'].replace('Z', '+00:00'))
54 | self.reset_time = reset_time.timestamp()
55 | logger.info(f"Rate limit update - Reset time: {reset_time.isoformat()}")
56 |
57 | self.last_request_time = time.time()
58 |
59 | def should_rate_limit(self) -> bool:
60 | """Check if we should rate limit."""
61 | current_time = time.time()
62 |
63 | # If we have no requests remaining, check if reset time has passed
64 | if self.requests_remaining <= 0:
65 | if self.reset_time and current_time < self.reset_time:
66 | logger.warning(f"Rate limit active - No requests remaining until {datetime.fromtimestamp(self.reset_time).isoformat()}")
67 | return True
68 |
69 | # Ensure minimum 200ms between requests as a safety measure
70 | time_since_last = current_time - self.last_request_time
71 | if time_since_last < 0.2:
72 | logger.info(f"Rate limit spacing - Only {time_since_last:.3f}s since last request (minimum 0.2s)")
73 | return True
74 |
75 | return False
76 |
77 | def wait_if_needed(self) -> None:
78 | """Wait if rate limiting is needed."""
79 | while self.should_rate_limit():
80 | current_time = time.time()
81 | wait_time = 0.2 # Default wait 200ms
82 |
83 | if self.reset_time and current_time < self.reset_time:
84 | wait_time = max(wait_time, self.reset_time - current_time)
85 | logger.warning(f"Rate limit wait - Waiting {wait_time:.2f}s for rate limit reset. Requests remaining: {self.requests_remaining}")
86 | else:
87 | # If we're just enforcing minimum spacing
88 | wait_time = max(0.2, 0.2 - (current_time - self.last_request_time))
89 | logger.info(f"Rate limit spacing - Waiting {wait_time:.3f}s between requests")
90 |
91 | time.sleep(wait_time)
92 |
93 | # Update current time after wait
94 | current_time = time.time()
95 | if self.reset_time and current_time >= self.reset_time:
96 | logger.info("Rate limit reset period has passed")
97 | self.requests_remaining = 50 # Reset to default limit
98 | self.reset_time = None
99 |
100 | @dataclass
101 | class ToolResponse:
102 | """Response from a tool call."""
103 | tool_name: str
104 | output: Any
105 | next_action: Optional[Dict[str, Any]] = None
106 |
107 | class ClaudeClient:
108 | """Client for interacting with Claude API."""
109 |
110 | # Define available tools and their schemas
111 | TOOLS = [
112 | {
113 | "name": "fetch_files",
114 | "description": "Search for files containing specific patterns or strings",
115 | "input_schema": {
116 | "type": "object",
117 | "properties": {
118 | "search_patterns": {
119 | "type": "array",
120 | "items": {"type": "string"},
121 | "description": "List of strings or patterns to search for in files"
122 | },
123 | "currentAnalysis": {
124 | "type": "string",
125 | "description": "Current state of analysis - include your ongoing analysis, findings, and hypotheses"
126 | }
127 | },
128 | "required": ["search_patterns", "currentAnalysis"]
129 | }
130 | },
131 | {
132 | "name": "fetch_logs",
133 | "description": "Fetch a specific page of logs for analysis. Pages are numbered from 1 to total_pages. Request the next page number to fetch.",
134 | "input_schema": {
135 | "type": "object",
136 | "properties": {
137 | "page_number": {
138 | "type": "integer",
139 | "description": "Next page number of logs to fetch (1-based indexing)"
140 | },
141 | "currentAnalysis": {
142 | "type": "string",
143 | "description": "Current state of analysis - include your ongoing analysis, findings, and hypotheses"
144 | }
145 | },
146 | "required": ["page_number", "currentAnalysis"]
147 | }
148 | },
149 | {
150 | "name": "fetch_code",
151 | "description": "Fetch code from a specific file and line number",
152 | "input_schema": {
153 | "type": "object",
154 | "properties": {
155 | "filename": {
156 | "type": "string",
157 | "description": "Path to the file to analyze"
158 | },
159 | "line_number": {
160 | "type": "integer",
161 | "description": "Line number to focus analysis on"
162 | },
163 | "currentAnalysis": {
164 | "type": "string",
165 | "description": "Current state of analysis - include your ongoing analysis, findings, and hypotheses"
166 | }
167 | },
168 | "required": ["filename", "line_number", "currentAnalysis"]
169 | }
170 | },
171 | {
172 | "name": "show_root_cause",
173 | "description": "Display final root cause analysis when sufficient information is available",
174 | "input_schema": {
175 | "type": "object",
176 | "properties": {
177 | "root_cause": {
178 | "type": "string",
179 | "description": "Detailed explanation of the root cause and recommendations"
180 | },
181 | "currentAnalysis": {
182 | "type": "string",
183 | "description": "Current state of analysis - include your ongoing analysis, findings, and hypotheses"
184 | }
185 | },
186 | "required": ["root_cause", "currentAnalysis"]
187 | }
188 | }
189 | ]
190 |
191 | def __init__(self, api_key: Optional[str] = None, model: str = "claude-3-7-sonnet-latest"):
192 | """
193 | Initialize Claude API client.
194 |
195 | Args:
196 | api_key: Anthropic API key.
197 | model: Claude model to use.
198 | """
199 | self.api_key = api_key
200 | if not self.api_key:
201 | raise ValueError("API key must be provided either as argument or in ANTHROPIC_API_KEY environment variable")
202 |
203 | self.model = model
204 | self.client = Anthropic(api_key=self.api_key)
205 | self.max_tokens = 4096
206 | self.rate_limit_state = RateLimitState()
207 | self.analyzed_pages = set() # Track which pages have been analyzed
208 |
209 | def analyze_error(self, error_input: str, findings: Optional[List[Dict[str, Any]]], current_analysis: Optional[str] = None) -> Dict[str, Any]:
210 | """
211 | Ask Claude to analyze an error and suggest next steps.
212 |
213 | Args:
214 | error_input: The error or log content to analyze
215 | findings: List of all findings so far (contains only metadata, not content)
216 | current_analysis: Current state of analysis maintained by LLM
217 |
218 | Returns:
219 | Dictionary with:
220 | - tool: The name of the tool to use
221 | - params: Parameters for the tool
222 | - analysis: Any additional analysis text
223 | - error: Optional error message if something went wrong
224 | - current_analysis: Updated analysis state from LLM
225 | """
226 | try:
227 | # Wait if rate limiting is needed
228 | self.rate_limit_state.wait_if_needed()
229 |
230 | # Format findings for the prompt
231 | findings_str = ""
232 | page_info = ""
233 | if findings:
234 | findings_str = "\nPrevious findings:\n"
235 | for k, v in findings.items():
236 | logger.info(f"Finding: {k} - {v}")
237 | findings_str += f"{k}: {v}\n"
238 |
239 | prompt = f"""
240 | You are an expert system debugging assistant. Analyze this error and determine the next step.
241 |
242 | ERROR CONTEXT:
243 | {error_input}
244 |
245 | {findings_str}
246 |
247 | Current Analysis State:
248 | {current_analysis if current_analysis else "No previous analysis"}
249 |
250 | Choose the appropriate tool to continue the investigation:
251 | 1. fetch_files: Search for files containing specific patterns
252 | - Use this tool with "search_patterns" parameter as an array of strings
253 | - Example: {{"tool": "fetch_files", "params": {{"search_patterns": ["error", "exception"], "currentAnalysis": "..."}}}}
254 |
255 | 2. fetch_logs: Get a specific page of logs
256 | - Use this tool with "page_number" parameter
257 | - Example: {{"tool": "fetch_logs", "params": {{"page_number": 2, "currentAnalysis": "..."}}}}
258 |
259 | 3. fetch_code: Get code from a specific file and line
260 | - Use this tool with "filename" and "line_number" parameters
261 | - Example: {{"tool": "fetch_code", "params": {{"filename": "app.py", "line_number": 42, "currentAnalysis": "..."}}}}
262 |
263 | 4. show_root_cause: If you have enough information to determine the root cause
264 | - Use this tool with "root_cause" parameter
265 | - Example: {{"tool": "show_root_cause", "params": {{"root_cause": "The error occurs because...", "currentAnalysis": "..."}}}}
266 |
267 | IMPORTANT INSTRUCTIONS:
268 | 1. Maintain your analysis state in your response. Include key findings, hypotheses, and next steps.
269 | 2. Use the current analysis state to avoid repeating searches or analysis.
270 | 3. If you hit a rate limit, wait and try with a smaller context in the next request.
271 | 4. For fetch_logs:
272 | - NEVER request a page that has already been analyzed
273 | - ALWAYS use the exact page number specified in "NEXT PAGE TO REQUEST" in the header
274 | - If you see "ALL PAGES HAVE BEEN ANALYZED", use show_root_cause instead
275 |
276 | Respond with:
277 | 1. Your updated analysis of the situation
278 | 2. The most appropriate next tool and its parameters
279 |
280 | Your response should clearly separate the analysis state from the tool choice.
281 | """
282 | # Call Claude using the SDK
283 | response = self.client.messages.create(
284 | model=self.model,
285 | max_tokens=self.max_tokens,
286 | messages=[{"role": "user", "content": prompt}],
287 | tools=self.TOOLS,
288 | tool_choice={"type": "any"}
289 | )
290 |
291 | # Update rate limit state from response headers
292 | if hasattr(response, '_response'):
293 | self.rate_limit_state.update_from_headers(response._response.headers)
294 |
295 | logger.debug(f"Raw API response: {json.dumps(response.model_dump(), indent=2)}")
296 |
297 | # Extract tool choice and analysis from content array
298 | content = response.content
299 | tool_response = None
300 | updated_analysis = None
301 |
302 | # Look for tool_use and text in content array
303 | for item in content:
304 | if item.type == 'tool_use':
305 | tool_response = {
306 | 'tool': item.name,
307 | 'params': item.input,
308 | 'analysis': '', # Tool calls don't include analysis text
309 | 'error': None
310 | }
311 | elif item.type == 'text':
312 | # The text response contains both analysis and state
313 | text_parts = item.text.split("\nTool Choice:", 1)
314 | if len(text_parts) > 1:
315 | updated_analysis = text_parts[0].strip()
316 | # Tool choice is handled by tool_use
317 | else:
318 | updated_analysis = item.text.strip()
319 |
320 | # If no valid content found, use empty response
321 | if not tool_response:
322 | tool_response = {
323 | 'tool': None,
324 | 'params': {},
325 | 'analysis': 'No valid response from LLM',
326 | 'error': None
327 | }
328 |
329 | # Add the updated analysis to the response
330 | tool_response['current_analysis'] = updated_analysis
331 |
332 | logger.info(f"LLM suggested tool: {tool_response['tool']}")
333 | if tool_response['params']:
334 | logger.info(f"Tool parameters: {json.dumps(tool_response['params'], indent=2)}")
335 |
336 | return tool_response
337 |
338 | except Exception as e:
339 | error_msg = str(e)
340 | logger.error(f"Error during LLM analysis: {error_msg}")
341 |
342 | # Handle rate limit errors specially
343 | if "rate_limit_error" in error_msg:
344 | time.sleep(5) # Wait 5 seconds before next attempt
345 | return {
346 | 'tool': None,
347 | 'params': {},
348 | 'analysis': 'Rate limit reached. Please try again with a smaller context.',
349 | 'error': 'Rate limit error',
350 | 'current_analysis': current_analysis # Preserve the current analysis
351 | }
352 |
353 | return {
354 | 'tool': None,
355 | 'params': {},
356 | 'analysis': '',
357 | 'error': error_msg,
358 | 'current_analysis': current_analysis # Preserve the current analysis
359 | }
360 |
361 | def analyze_code(self, code: str, file_path: str, line_number: int) -> str:
362 | """
363 | Ask Claude to analyze a code snippet.
364 | """
365 | logger.info(f"=== Starting code analysis for {file_path}:{line_number} ===")
366 | logger.info(f"Code length: {len(code)} chars")
367 |
368 | try:
369 | # Wait if rate limiting is needed
370 | self.rate_limit_state.wait_if_needed()
371 |
372 | prompt = f"""
373 | Analyze this code snippet and explain what it does, focusing on line {line_number}.
374 | Pay special attention to potential issues or bugs.
375 |
376 | File: {file_path}
377 | Line: {line_number}
378 |
379 | CODE:
380 | {code}
381 | """
382 | response = self.client.messages.create(
383 | model=self.model,
384 | max_tokens=self.max_tokens,
385 | messages=[{"role": "user", "content": prompt}]
386 | )
387 |
388 | # Update rate limit state from response headers
389 | if hasattr(response, '_response'):
390 | self.rate_limit_state.update_from_headers(response._response.headers)
391 |
392 | analysis = response.content[0].text if response.content else "No analysis provided"
393 | logger.info(f"Code analysis received: {len(analysis)} chars")
394 | return analysis
395 |
396 | except Exception as e:
397 | logger.error(f"Error during code analysis: {str(e)}")
398 | return f"Error analyzing code: {str(e)}"
399 | finally:
400 | logger.info("=== Code analysis complete ===")
401 |
402 | def analyze_logs(self, logs: str, current_page: int, total_pages: int) -> str:
403 | """
404 | Ask Claude to analyze log content.
405 |
406 | Args:
407 | logs: The log content to analyze
408 | current_page: Current page number (1-based)
409 | total_pages: Total number of available log pages
410 | """
411 | logger.info("=== Starting log analysis ===")
412 | logger.info(f"Log length: {len(logs)} chars")
413 | logger.info(f"Analyzing page {current_page} of {total_pages}")
414 | logger.info(f"Previously analyzed pages: {sorted(list(self.analyzed_pages))}")
415 |
416 | # Add this page to analyzed pages before analysis
417 | # This ensures it's tracked even if analysis fails
418 | self.analyzed_pages.add(current_page)
419 |
420 | try:
421 | # Wait if rate limiting is needed
422 | self.rate_limit_state.wait_if_needed()
423 |
424 | prompt = f"""
425 | Analyze these logs and identify:
426 | 1. Any error patterns or issues
427 | 2. Relevant context around the errors
428 | 3. Potential root causes
429 | 4. Suggested next steps for investigation
430 |
431 | You are looking at page {current_page} out of {total_pages} total pages of logs.
432 | You have already analyzed pages: {sorted(list(self.analyzed_pages))}
433 | If you need to see other pages, you can request them using the fetch_logs tool, but avoid requesting pages you've already analyzed.
434 |
435 | IMPORTANT: If you hit a rate limit, try analyzing with less context in your next request.
436 |
437 | LOGS:
438 | {logs}"""
439 |
440 | response = self.client.messages.create(
441 | model=self.model,
442 | max_tokens=self.max_tokens,
443 | messages=[{"role": "user", "content": prompt}]
444 | )
445 |
446 | # Update rate limit state from response headers
447 | if hasattr(response, '_response'):
448 | self.rate_limit_state.update_from_headers(response._response.headers)
449 |
450 | analysis = response.content[0].text if response.content else "No analysis provided"
451 | logger.info(f"Log analysis received: {len(analysis)} chars")
452 | return analysis
453 |
454 | except Exception as e:
455 | error_msg = str(e)
456 | logger.error(f"Error during log analysis: {error_msg}")
457 |
458 | # Handle rate limit errors specially
459 | if "rate_limit_error" in error_msg:
460 | time.sleep(5) # Wait 5 seconds before next attempt
461 | return "Rate limit reached. Please try again with a smaller context."
462 |
463 | return f"Error analyzing logs: {error_msg}"
464 | finally:
465 | logger.info("=== Log analysis complete ===")
466 |
467 | def analyze_entry_point(self, logs: str, entry_point: str) -> str:
468 | """
469 | Ask Claude to analyze a specific log entry point.
470 | """
471 | logger.info("=== Starting entry point analysis ===")
472 | logger.info(f"Entry point: {entry_point}")
473 |
474 | try:
475 | # Wait if rate limiting is needed
476 | self.rate_limit_state.wait_if_needed()
477 |
478 | prompt = f"""
479 | Analyze this specific log entry and its context:
480 |
481 | ENTRY POINT:
482 | {entry_point}
483 |
484 | FULL LOGS:
485 | {logs}
486 |
487 | Explain:
488 | 1. What this log entry indicates
489 | 2. Relevant context before and after
490 | 3. Any patterns or issues related to this entry
491 | """
492 | response = self.client.messages.create(
493 | model=self.model,
494 | max_tokens=self.max_tokens,
495 | messages=[{"role": "user", "content": prompt}]
496 | )
497 |
498 | # Update rate limit state from response headers
499 | if hasattr(response, '_response'):
500 | self.rate_limit_state.update_from_headers(response._response.headers)
501 |
502 | analysis = response.content[0].text if response.content else "No analysis provided"
503 | logger.info(f"Entry point analysis received: {len(analysis)} chars")
504 | return analysis
505 |
506 | except Exception as e:
507 | logger.error(f"Error during entry point analysis: {str(e)}")
508 | return f"Error analyzing entry point: {str(e)}"
509 | finally:
510 | logger.info("=== Entry point analysis complete ===")
511 |
--------------------------------------------------------------------------------
/vscode-extension/src/settingsView.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 | import * as path from 'path';
3 | import * as fs from 'fs';
4 | import { loadLogs } from './processor';
5 |
6 | /**
7 | * Settings webview panel for managing traceback extension settings
8 | */
9 | export class SettingsView {
10 | public static currentPanel: SettingsView | undefined;
11 | private readonly _panel: vscode.WebviewPanel;
12 | private _disposables: vscode.Disposable[] = [];
13 | private readonly _extensionContext: vscode.ExtensionContext;
14 |
15 | /**
16 | * Create or show the settings panel
17 | */
18 | public static createOrShow(extensionContext: vscode.ExtensionContext) {
19 | const column = vscode.window.activeTextEditor
20 | ? vscode.window.activeTextEditor.viewColumn
21 | : undefined;
22 |
23 | // If we already have a panel, show it
24 | if (SettingsView.currentPanel) {
25 | SettingsView.currentPanel._panel.reveal(column);
26 | return;
27 | }
28 |
29 | // Otherwise, create a new panel
30 | const panel = vscode.window.createWebviewPanel(
31 | 'tracebackSettings',
32 | 'TraceBack Settings',
33 | column || vscode.ViewColumn.One,
34 | {
35 | enableScripts: true,
36 | retainContextWhenHidden: true,
37 | localResourceRoots: [
38 | vscode.Uri.file(path.join(extensionContext.extensionPath, 'resources'))
39 | ]
40 | }
41 | );
42 |
43 | SettingsView.currentPanel = new SettingsView(panel, extensionContext);
44 | }
45 |
46 | private constructor(panel: vscode.WebviewPanel, context: vscode.ExtensionContext) {
47 | this._panel = panel;
48 | this._extensionContext = context;
49 |
50 | // Initial content
51 | this._update().catch(err => console.error('Error updating settings view:', err));
52 |
53 | // Listen for when the panel is disposed
54 | this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
55 |
56 | // Update the content when the view changes
57 | this._panel.onDidChangeViewState(
58 | async e => {
59 | if (this._panel.visible) {
60 | await this._update();
61 | }
62 | },
63 | null,
64 | this._disposables
65 | );
66 |
67 | // Handle messages from the webview
68 | this._panel.webview.onDidReceiveMessage(
69 | async message => {
70 | switch (message.command) {
71 | case 'selectLogFile':
72 | await this._selectLogFile();
73 | break;
74 | case 'loadFromUrl':
75 | await this._loadFromUrl(message.url);
76 | break;
77 | case 'loadFromText':
78 | await this._loadFromText(message.text);
79 | break;
80 | case 'loadRustLogs':
81 | await this._loadRustLogs(message.text);
82 | break;
83 | case 'saveAxiomSettings':
84 | await this._saveAxiomSettings(message.apiKey, message.dataset, message.query);
85 | break;
86 | case 'saveClaudeApiKey':
87 | await this._saveClaudeApiKey(message.apiKey);
88 | break;
89 | case 'selectRepository':
90 | await this._selectRepository();
91 | break;
92 | }
93 | },
94 | null,
95 | this._disposables
96 | );
97 | }
98 |
99 | /**
100 | * Handle log file selection
101 | */
102 | private async _selectLogFile() {
103 | const options: vscode.OpenDialogOptions = {
104 | canSelectFiles: true,
105 | canSelectFolders: false,
106 | canSelectMany: false,
107 | openLabel: 'Select Log File',
108 | filters: {
109 | 'Log Files': ['log', 'json'],
110 | 'All Files': ['*'],
111 | },
112 | };
113 |
114 | const fileUri = await vscode.window.showOpenDialog(options);
115 | if (fileUri && fileUri[0]) {
116 | const logPath = fileUri[0].fsPath;
117 | try {
118 | // Read the file content
119 | const content = fs.readFileSync(logPath, 'utf8');
120 |
121 | // Save both the file path and content
122 | await this._extensionContext.globalState.update('logFilePath', logPath);
123 | await this._extensionContext.globalState.update('logContent', content);
124 |
125 | // Refresh logs in the explorer
126 | vscode.commands.executeCommand('traceback.refreshLogs');
127 |
128 | // Notify webview about the change
129 | this._panel.webview.postMessage({
130 | command: 'updateLogFilePath',
131 | path: logPath
132 | });
133 | } catch (error) {
134 | vscode.window.showErrorMessage(`Failed to read log file: ${error}`);
135 | }
136 | }
137 | }
138 |
139 | /**
140 | * Handle loading logs from a URL
141 | */
142 | private async _loadFromUrl(url: string) {
143 | if (!url || !url.startsWith('http')) {
144 | vscode.window.showErrorMessage('Please enter a valid URL starting with http:// or https://');
145 | return;
146 | }
147 |
148 | await this._extensionContext.globalState.update('logFilePath', url);
149 |
150 | // Refresh logs in the explorer
151 | vscode.commands.executeCommand('traceback.refreshLogs');
152 |
153 | // Notify webview of success
154 | this._panel.webview.postMessage({
155 | command: 'updateStatus',
156 | message: `Loaded logs from URL: ${url}`
157 | });
158 | }
159 |
160 | /**
161 | * Handle loading logs from pasted text
162 | */
163 | private async _loadFromText(text: string) {
164 | if (!text || text.trim().length === 0) {
165 | vscode.window.showErrorMessage('Please paste log content first');
166 | return;
167 | }
168 |
169 | try {
170 | // Create a temporary file in the OS temp directory
171 | const tempDir = path.join(this._extensionContext.globalStorageUri.fsPath, 'temp');
172 | if (!fs.existsSync(tempDir)) {
173 | fs.mkdirSync(tempDir, { recursive: true });
174 | }
175 |
176 | const tempFilePath = path.join(tempDir, `pasted_logs_${Date.now()}.log`);
177 | fs.writeFileSync(tempFilePath, text);
178 |
179 | // Save both the file path and content
180 | await this._extensionContext.globalState.update('logFilePath', tempFilePath);
181 | await this._extensionContext.globalState.update('logContent', text);
182 |
183 | // Refresh logs in the explorer
184 | vscode.commands.executeCommand('traceback.refreshLogs');
185 |
186 | // Notify webview of success
187 | this._panel.webview.postMessage({
188 | command: 'updateStatus',
189 | message: 'Loaded logs from pasted text'
190 | });
191 | } catch (error) {
192 | vscode.window.showErrorMessage(`Failed to process pasted logs: ${error}`);
193 | }
194 | }
195 |
196 | private async _loadRustLogs(text: string) {
197 | if (!text || text.trim().length === 0) {
198 | vscode.window.showErrorMessage('Please paste Rust log content first');
199 | return;
200 | }
201 |
202 | try {
203 | console.log('Processing Rust logs...');
204 |
205 | // First parse the logs to validate them
206 | const logs = await loadLogs(text);
207 | if (!logs || logs.length === 0) {
208 | vscode.window.showErrorMessage('No valid Rust logs found in the content');
209 | return;
210 | }
211 |
212 | // Create a temporary file in the OS temp directory
213 | const tempDir = path.join(this._extensionContext.globalStorageUri.fsPath, 'temp');
214 | console.log('Temp directory:', tempDir);
215 |
216 | if (!fs.existsSync(tempDir)) {
217 | console.log('Creating temp directory...');
218 | fs.mkdirSync(tempDir, { recursive: true });
219 | }
220 |
221 | const tempFilePath = path.join(tempDir, `rust_logs_${Date.now()}.log`);
222 | console.log('Writing to temp file:', tempFilePath);
223 |
224 | // Split the text into lines and process each line
225 | const lines = text.split('\n');
226 | const processedLines = lines.map(line => {
227 | // Skip empty lines
228 | if (!line.trim()) return '';
229 |
230 | // Check if it's already in the right format
231 | if (line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
232 | return line;
233 | }
234 |
235 | // Convert Python-style timestamp to ISO format
236 | const match = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(INFO|WARN|ERROR|DEBUG)\s+\[([^\]]+)\]\s+(.+)$/);
237 | if (match) {
238 | const [_, timestamp, level, source, message] = match;
239 | // Convert space to T in timestamp
240 | const isoTimestamp = timestamp.replace(' ', 'T');
241 | return `${isoTimestamp}Z ${level} ${source}: ${message}`;
242 | }
243 |
244 | return line;
245 | }).join('\n');
246 |
247 | // Ensure the text ends with a newline
248 | const normalizedText = processedLines.endsWith('\n') ? processedLines : processedLines + '\n';
249 | fs.writeFileSync(tempFilePath, normalizedText);
250 |
251 | console.log('Setting global state...');
252 | // Save both the file path and content
253 | await this._extensionContext.globalState.update('logFilePath', tempFilePath);
254 | await this._extensionContext.globalState.update('logContent', normalizedText);
255 | await this._extensionContext.globalState.update('logFormat', 'rust');
256 |
257 | // Show success message to user
258 | vscode.window.showInformationMessage('Rust logs loaded successfully');
259 |
260 | // Refresh logs in the explorer
261 | console.log('Refreshing logs...');
262 | await vscode.commands.executeCommand('traceback.refreshLogs');
263 |
264 | // Notify webview of success
265 | this._panel.webview.postMessage({
266 | command: 'updateStatus',
267 | message: 'Loaded Rust logs successfully'
268 | });
269 |
270 | // Update the current file path display
271 | this._panel.webview.postMessage({
272 | command: 'updateLogFilePath',
273 | path: tempFilePath
274 | });
275 | } catch (error) {
276 | console.error('Error processing Rust logs:', error);
277 | vscode.window.showErrorMessage(`Failed to process Rust logs: ${error}`);
278 | }
279 | }
280 |
281 | /**
282 | * Handle saving Axiom settings and loading a trace
283 | */
284 | private async _saveAxiomSettings(apiKey: string, dataset: string, query: string) {
285 | if (apiKey) {
286 | await this._extensionContext.secrets.store('axiom-token', apiKey);
287 | }
288 |
289 | if (dataset) {
290 | await this._extensionContext.globalState.update('axiomDataset', dataset);
291 | }
292 |
293 | // If query is a trace ID, load it
294 | if (query && query.trim()) {
295 | await this._extensionContext.globalState.update('axiomTraceId', query);
296 | await this._extensionContext.globalState.update('logFilePath', `axiom:${query}`);
297 |
298 | // Refresh logs in the explorer
299 | vscode.commands.executeCommand('traceback.refreshLogs');
300 |
301 | // Notify webview of success
302 | this._panel.webview.postMessage({
303 | command: 'updateStatus',
304 | message: `Loading Axiom trace: ${query}`
305 | });
306 | }
307 | }
308 |
309 | /**
310 | * Save Claude API key to workspace configuration
311 | */
312 | private async _saveClaudeApiKey(apiKey: string) {
313 | if (!apiKey || apiKey.trim().length === 0) {
314 | vscode.window.showWarningMessage('Please enter a valid Claude API key');
315 | return;
316 | }
317 |
318 | try {
319 | // Store the API key in the secure storage
320 | await vscode.workspace.getConfiguration('traceback').update('claudeApiKey', apiKey, true);
321 |
322 | // Also update the ClaudeService instance
323 | const claudeService = (await import('./claudeService')).ClaudeService.getInstance();
324 | await claudeService.setApiKey(apiKey);
325 |
326 | // Notify webview of success
327 | this._panel.webview.postMessage({
328 | command: 'updateStatus',
329 | message: 'Claude API key saved successfully'
330 | });
331 |
332 | // Update the API key input field placeholder to indicate it's set
333 | this._panel.webview.postMessage({
334 | command: 'updateClaudeApiKey',
335 | isSet: true
336 | });
337 |
338 | vscode.window.showInformationMessage('Claude API key saved successfully');
339 | } catch (error) {
340 | console.error('Error saving Claude API key:', error);
341 | vscode.window.showErrorMessage(`Failed to save Claude API key: ${error}`);
342 | }
343 | }
344 |
345 | /**
346 | * Handle repository selection
347 | */
348 | private async _selectRepository() {
349 | const options: vscode.OpenDialogOptions = {
350 | canSelectFiles: false,
351 | canSelectFolders: true,
352 | canSelectMany: false,
353 | openLabel: 'Select Repository Root',
354 | title: 'Select Repository Root Directory',
355 | };
356 |
357 | const fileUri = await vscode.window.showOpenDialog(options);
358 | if (fileUri && fileUri[0]) {
359 | const repoPath = fileUri[0].fsPath;
360 | await this._extensionContext.globalState.update('repoPath', repoPath);
361 |
362 | // Open the selected folder in VS Code
363 | await vscode.commands.executeCommand('vscode.openFolder', fileUri[0], {
364 | forceNewWindow: false,
365 | });
366 |
367 | // Show confirmation message
368 | vscode.window.showInformationMessage(`Repository path set to: ${repoPath}`);
369 | }
370 | }
371 |
372 | /**
373 | * Update webview content
374 | */
375 | private async _update() {
376 | this._panel.title = 'TraceBack Settings';
377 |
378 | // Check if Claude API key is set
379 | const config = vscode.workspace.getConfiguration('traceback');
380 | const claudeApiKey = config.get('claudeApiKey');
381 | const isApiKeySet = !!claudeApiKey;
382 |
383 | // Store the state so we can use it in the HTML template
384 | await this._extensionContext.workspaceState.update('claudeApiKeySet', isApiKeySet);
385 |
386 | // Generate and set the webview HTML
387 | this._panel.webview.html = this._getHtmlForWebview();
388 |
389 | // Update the Claude API key status in the webview after it's loaded
390 | setTimeout(() => {
391 | this._panel.webview.postMessage({
392 | command: 'updateClaudeApiKey',
393 | isSet: isApiKeySet
394 | });
395 | }, 500);
396 | }
397 |
398 | /**
399 | * Generate HTML content for the webview
400 | */
401 | private _getHtmlForWebview() {
402 | // Get current settings
403 | const logFilePath = this._extensionContext.globalState.get('logFilePath') || '';
404 | const repoPath = this._extensionContext.globalState.get('repoPath') || '';
405 |
406 | return `
407 |
408 |
409 |
410 |
411 | TraceBack Settings
412 |
519 |
520 |
521 |
522 |