├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue-template.md ├── pull_request_template.md └── workflows │ └── build.yaml ├── LICENSE ├── README.md ├── cli ├── .gitignore ├── .python-version ├── LICENSE ├── README.md ├── pyproject.toml └── tracebackapp │ ├── __init__.py │ ├── main.py │ ├── tools │ ├── __init__.py │ ├── analysis_tools.py │ ├── claude_client.py │ └── commands.py │ └── tui │ ├── __init__.py │ └── app.py └── vscode-extension ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── CONTRIBUTING.md ├── LICENSE ├── package-lock.json ├── package.json ├── resources ├── hyperdrive-logo.png └── log-icon.svg ├── src ├── callStackExplorer.ts ├── claudeService.ts ├── decorations.ts ├── extension.ts ├── logExplorer.ts ├── processor.ts ├── rustLogParser.test.ts ├── rustLogParser.ts ├── settingsView.ts ├── spanVisualizerPanel.ts ├── variableDecorator.ts └── variableExplorer.ts ├── tsconfig.json └── webpack.config.js /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ![Traceback 0 5 Boomerang demo](https://github.com/user-attachments/assets/6fb626fa-84da-4e62-9963-64f97d9a80bf) 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 | ![image](https://github.com/user-attachments/assets/dfb9a791-2694-4883-b890-17460f197244) 16 | 17 | 1. Add your Rust [`tracing`](https://docs.rs/tracing/latest/tracing/) logs 18 | 19 | ![image](https://github.com/user-attachments/assets/79ac974d-9b1f-40be-82c2-5987a6d1877b) 20 | 21 | 1. Select your Rust repository 22 | 23 | ![image](https://github.com/user-attachments/assets/22211e44-8210-42df-a8b0-9840e99cb1bb) 24 | 25 | 1. Set your Claude API Key 26 | 27 | ![image](https://github.com/user-attachments/assets/8da0d66e-8b4c-4284-a1c6-3eb519c4a19e) 28 | 29 | ## Features 30 | 31 | 1. Visualise spans associated with a log 32 | 33 | ![image](https://github.com/user-attachments/assets/6f5a2f48-8133-4096-87b6-8e90c65a5abc) 34 | 35 | 1. Find the line of code associated with a log 36 | 37 | ![image](https://github.com/user-attachments/assets/6cd4fe76-4ec7-4520-88a6-5524f29ce5fe) 38 | 39 | 1. Navigate the call stack associated with a log 40 | 41 | ![image](https://github.com/user-attachments/assets/55041404-91f2-40c0-a905-5a0068633a14) 42 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /cli/.python-version: -------------------------------------------------------------------------------- 1 | traceback-cli-env 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /cli/tracebackapp/__init__.py: -------------------------------------------------------------------------------- 1 | """Traceback - A terminal-based AI chat interface.""" 2 | 3 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /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() -------------------------------------------------------------------------------- /cli/tracebackapp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools for root cause analysis in the Traceback CLI.""" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /cli/tracebackapp/tui/__init__.py: -------------------------------------------------------------------------------- 1 | """TUI module for Traceback.""" -------------------------------------------------------------------------------- /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/.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 | } -------------------------------------------------------------------------------- /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/.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/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 -------------------------------------------------------------------------------- /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/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/resources/hyperdrive-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbackai/traceback/ae000b708934168f87935c093b0960a840896aa8/vscode-extension/resources/hyperdrive-logo.png -------------------------------------------------------------------------------- /vscode-extension/resources/log-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /vscode-extension/src/callStackExplorer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { RustLogEntry } from './logExplorer'; 3 | import { CallerAnalysis } from './claudeService'; 4 | import { ClaudeService } from './claudeService'; 5 | import * as path from 'path'; 6 | import { logLineDecorationType } from './decorations'; 7 | 8 | interface LanguageSupport { 9 | extensionId: string; 10 | displayName: string; 11 | symbolProvider?: boolean; // Whether to use VSCode's symbol provider 12 | referenceProvider?: boolean; // Whether to use VSCode's reference provider 13 | } 14 | 15 | // Map of supported languages and their required extensions 16 | const SUPPORTED_LANGUAGES: Record = { 17 | 'go': { 18 | extensionId: 'golang.go', 19 | displayName: 'Go', 20 | symbolProvider: true, 21 | referenceProvider: true 22 | }, 23 | 'rust': { 24 | extensionId: 'rust-lang.rust-analyzer', 25 | displayName: 'Rust', 26 | symbolProvider: true, 27 | referenceProvider: true 28 | }, 29 | 'typescript': { 30 | extensionId: 'vscode.typescript-language-features', 31 | displayName: 'TypeScript', 32 | symbolProvider: true, 33 | referenceProvider: true 34 | }, 35 | 'javascript': { 36 | extensionId: 'vscode.typescript-language-features', 37 | displayName: 'JavaScript', 38 | symbolProvider: true, 39 | referenceProvider: true 40 | }, 41 | 'python': { 42 | extensionId: 'ms-python.python', 43 | displayName: 'Python', 44 | symbolProvider: true, 45 | referenceProvider: true 46 | }, 47 | 'java': { 48 | extensionId: 'redhat.java', 49 | displayName: 'Java', 50 | symbolProvider: true, 51 | referenceProvider: true 52 | } 53 | }; 54 | 55 | interface CallerNode { 56 | filePath: string; 57 | lineNumber: number; 58 | code: string; 59 | functionName: string; 60 | confidence: number; 61 | explanation: string; 62 | children?: CallerNode[]; 63 | isLoading?: boolean; 64 | } 65 | 66 | interface CallStackCache { 67 | children: CallerNode[]; 68 | lastUpdated: string; 69 | } 70 | 71 | /** 72 | * Helper function to check if a file path is a test file 73 | */ 74 | function isTestPath(filePath: string): boolean { 75 | const normalizedPath = filePath.toLowerCase(); 76 | return normalizedPath.includes('/test/') || 77 | normalizedPath.includes('/tests/') || 78 | normalizedPath.includes('/__tests__/') || 79 | normalizedPath.includes('/__test__/') || 80 | normalizedPath.includes('.test.') || 81 | normalizedPath.includes('.spec.') || 82 | normalizedPath.includes('_test.') || 83 | normalizedPath.includes('_spec.'); 84 | } 85 | 86 | /** 87 | * TreeItem for call stack entries in the Call Stack Explorer 88 | */ 89 | export class CallStackTreeItem extends vscode.TreeItem { 90 | constructor( 91 | public readonly caller: CallerNode, 92 | public readonly provider: CallStackExplorerProvider, 93 | public readonly isExpanded: boolean = false 94 | ) { 95 | super( 96 | caller.filePath ? `${path.basename(caller.filePath)}:${caller.lineNumber + 1}` : caller.code, 97 | caller.isLoading ? vscode.TreeItemCollapsibleState.None : 98 | caller.children || isExpanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed 99 | ); 100 | 101 | this.description = caller.confidence ? `(${Math.round(caller.confidence * 100)}% confidence)` : ''; 102 | this.iconPath = new vscode.ThemeIcon( 103 | caller.confidence > 0.7 ? 'debug-stackframe-focused' : 'debug-stackframe' 104 | ); 105 | 106 | this.command = { 107 | command: 'traceback.openCallStackLocation', 108 | title: 'Open File', 109 | arguments: [caller, this] 110 | }; 111 | 112 | if (caller.isLoading) { 113 | this.description = '$(sync~spin) Analyzing...'; 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Detail item for showing properties of a span 120 | */ 121 | export class SpanDetailItem extends vscode.TreeItem { 122 | constructor( 123 | public readonly label: string, 124 | public readonly value: string, 125 | public readonly contextValue: string = 'spanDetail' 126 | ) { 127 | super(label, vscode.TreeItemCollapsibleState.None); 128 | 129 | this.description = value; 130 | 131 | // Set icon based on property type 132 | this.iconPath = new vscode.ThemeIcon('symbol-property'); 133 | } 134 | } 135 | 136 | /** 137 | * Tree data provider for the Call Stack Explorer view 138 | */ 139 | export class CallStackExplorerProvider implements vscode.TreeDataProvider { 140 | private _onDidChangeTreeData: vscode.EventEmitter = 141 | new vscode.EventEmitter(); 142 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 143 | 144 | private currentLogEntry: RustLogEntry | undefined; 145 | private callerAnalysis: CallerNode[] = []; 146 | private claudeService: ClaudeService = ClaudeService.getInstance(); 147 | private isAnalyzing: boolean = false; 148 | private callStackCache: Map = new Map(); 149 | 150 | constructor(private context: vscode.ExtensionContext) {} 151 | 152 | /** 153 | * Set the spans for the current log entry and refresh the view 154 | */ 155 | public setLogEntry(log: RustLogEntry | undefined, isAnalyzing: boolean = false): void { 156 | this.currentLogEntry = log; 157 | this.callerAnalysis = []; 158 | this.isAnalyzing = isAnalyzing; 159 | this._onDidChangeTreeData.fire(); 160 | } 161 | 162 | /** 163 | * Clear the call stack 164 | */ 165 | public clearCallStack(): void { 166 | this.callerAnalysis = []; 167 | this.currentLogEntry = undefined; 168 | this._onDidChangeTreeData.fire(); 169 | } 170 | 171 | public getCallStackAnalysis(): CallerAnalysis { 172 | return { 173 | rankedCallers: this.callerAnalysis.map(caller => ({ 174 | filePath: caller.filePath, 175 | lineNumber: caller.lineNumber, 176 | code: caller.code, 177 | functionName: caller.functionName, 178 | confidence: caller.confidence, 179 | explanation: caller.explanation 180 | })) 181 | }; 182 | } 183 | 184 | public setCallStackAnalysisFromCache(analysis: CallerNode[]): void { 185 | // Set the top-level analysis 186 | this.callerAnalysis = analysis; 187 | 188 | // Recursively load cached children for the entire top-level analysis 189 | for (const topLevelCaller of this.callerAnalysis) { 190 | this.loadChildrenFromCacheRecursive(topLevelCaller); 191 | } 192 | 193 | // Force a full refresh of the tree to show the loaded cache 194 | this._onDidChangeTreeData.fire(undefined); 195 | } 196 | 197 | async findPotentialCallers(sourceFile: string, lineNumber: number): Promise> { 198 | const potentialCallers: Array<{ filePath: string; lineNumber: number; code: string; functionName: string; functionRange?: vscode.Range }> = []; 199 | 200 | try { 201 | // First, get the document 202 | const document = await vscode.workspace.openTextDocument(sourceFile); 203 | const languageId = document.languageId; 204 | console.log('Document language ID:', languageId); 205 | 206 | // Check if language is supported 207 | const languageSupport = SUPPORTED_LANGUAGES[languageId]; 208 | if (!languageSupport) { 209 | console.log('Language not supported:', languageId); 210 | vscode.window.showWarningMessage(`Call stack analysis is not supported for ${languageId} files yet.`); 211 | return potentialCallers; 212 | } 213 | 214 | // Check and activate required extension 215 | const extension = vscode.extensions.getExtension(languageSupport.extensionId); 216 | if (!extension) { 217 | console.log(`${languageSupport.displayName} extension not found`); 218 | vscode.window.showWarningMessage( 219 | `${languageSupport.displayName} extension (${languageSupport.extensionId}) is not installed. ` + 220 | `Please install it for better call stack analysis.` 221 | ); 222 | } else if (!extension.isActive) { 223 | console.log(`Activating ${languageSupport.displayName} extension...`); 224 | await extension.activate(); 225 | // Wait for language server to be ready 226 | await new Promise(resolve => setTimeout(resolve, 500)); 227 | } 228 | 229 | // Try using language server's symbol provider if available 230 | let symbols: vscode.DocumentSymbol[] | undefined; 231 | 232 | if (languageSupport.symbolProvider) { 233 | try { 234 | // Use standard symbol provider 235 | symbols = await vscode.commands.executeCommand( 236 | 'vscode.executeDocumentSymbolProvider', 237 | document.uri 238 | ); 239 | console.log(`${languageSupport.displayName} symbols:`, symbols); 240 | 241 | if (!symbols || symbols.length === 0) { 242 | // Try workspace symbols as fallback 243 | const workspaceSymbols = await vscode.commands.executeCommand( 244 | 'vscode.executeWorkspaceSymbolProvider', 245 | path.basename(document.uri.fsPath) 246 | ); 247 | console.log(`${languageSupport.displayName} workspace symbols:`, workspaceSymbols); 248 | 249 | if (workspaceSymbols) { 250 | symbols = workspaceSymbols.map(s => ({ 251 | name: s.name, 252 | detail: '', 253 | kind: s.kind, 254 | range: s.location.range, 255 | selectionRange: s.location.range, 256 | children: [] 257 | })); 258 | } 259 | } 260 | } catch (error) { 261 | console.log(`Error getting ${languageSupport.displayName} symbols:`, error); 262 | } 263 | } 264 | 265 | // If no symbols found, try generic symbol provider 266 | if (!symbols || symbols.length === 0) { 267 | symbols = await vscode.commands.executeCommand( 268 | 'vscode.executeDocumentSymbolProvider', 269 | document.uri 270 | ); 271 | console.log('Generic document symbols:', symbols); 272 | } 273 | 274 | // If still no symbols, try workspace symbols 275 | if (!symbols || symbols.length === 0) { 276 | const workspaceSymbols = await vscode.commands.executeCommand( 277 | 'vscode.executeWorkspaceSymbolProvider', 278 | path.basename(sourceFile) 279 | ); 280 | console.log('Workspace symbols:', workspaceSymbols); 281 | 282 | if (workspaceSymbols) { 283 | const fileSymbols = workspaceSymbols.filter(s => 284 | s.location.uri.fsPath === document.uri.fsPath 285 | ); 286 | symbols = fileSymbols.map(s => ({ 287 | name: s.name, 288 | detail: '', 289 | kind: s.kind, 290 | range: s.location.range, 291 | selectionRange: s.location.range, 292 | children: [] 293 | })); 294 | } 295 | } 296 | 297 | // If still no symbols found, return current line context 298 | if (!symbols || symbols.length === 0) { 299 | console.log('No symbols found for file:', sourceFile); 300 | potentialCallers.push({ 301 | filePath: sourceFile, 302 | lineNumber: lineNumber, 303 | code: document.lineAt(lineNumber).text.trim(), 304 | functionName: 'unknown' 305 | }); 306 | return potentialCallers; 307 | } 308 | 309 | // Helper function to find the enclosing symbol 310 | function findEnclosingSymbol(symbols: vscode.DocumentSymbol[], line: number): vscode.DocumentSymbol | undefined { 311 | for (const symbol of symbols) { 312 | if (symbol.range.contains(new vscode.Position(line, 0))) { 313 | // Check children first for more specific matches 314 | if (symbol.children.length > 0) { 315 | const childMatch = findEnclosingSymbol(symbol.children, line); 316 | if (childMatch) { 317 | return childMatch; 318 | } 319 | } 320 | // If no child contains the line, but this symbol does, return this symbol 321 | if (symbol.kind === vscode.SymbolKind.Function || 322 | symbol.kind === vscode.SymbolKind.Method || 323 | symbol.kind === vscode.SymbolKind.Constructor) { 324 | return symbol; 325 | } 326 | } 327 | } 328 | return undefined; 329 | } 330 | 331 | // Find the enclosing function/method 332 | const enclosingSymbol = findEnclosingSymbol(symbols, lineNumber); 333 | 334 | if (!enclosingSymbol) { 335 | console.log('No enclosing function/method found'); 336 | return potentialCallers; 337 | } 338 | 339 | // Get the selection range for the function name 340 | const selectionRange = enclosingSymbol.selectionRange; 341 | 342 | // Find references to this function/method if the language supports it 343 | if (languageSupport.referenceProvider) { 344 | const locations = await vscode.commands.executeCommand( 345 | 'vscode.executeReferenceProvider', 346 | document.uri, 347 | selectionRange.start 348 | ); 349 | 350 | if (locations) { 351 | for (const location of locations) { 352 | // Skip test files 353 | if (isTestPath(location.uri.fsPath)) { 354 | continue; 355 | } 356 | 357 | // Skip self-references (the function definition itself) 358 | if (location.uri.fsPath === sourceFile && 359 | location.range.start.line === selectionRange.start.line) { 360 | continue; 361 | } 362 | 363 | const callerDoc = await vscode.workspace.openTextDocument(location.uri); 364 | 365 | // Get the enclosing function of the reference 366 | const callerSymbols = await vscode.commands.executeCommand( 367 | 'vscode.executeDocumentSymbolProvider', 368 | location.uri 369 | ); 370 | 371 | const callerEnclosingSymbol = callerSymbols ? 372 | findEnclosingSymbol(callerSymbols, location.range.start.line) : 373 | undefined; 374 | 375 | // Get some context around the calling line 376 | const startLine = Math.max(0, location.range.start.line - 1); 377 | const endLine = Math.min(callerDoc.lineCount - 1, location.range.start.line + 1); 378 | const contextLines = []; 379 | for (let i = startLine; i <= endLine; i++) { 380 | contextLines.push(callerDoc.lineAt(i).text.trim()); 381 | } 382 | 383 | potentialCallers.push({ 384 | filePath: location.uri.fsPath, 385 | lineNumber: location.range.start.line, 386 | code: contextLines.join('\n'), 387 | functionName: callerEnclosingSymbol?.name || 'unknown', 388 | functionRange: callerEnclosingSymbol?.range 389 | }); 390 | } 391 | } 392 | } 393 | 394 | // If we found no references but have an enclosing function, 395 | // at least return that as a potential location 396 | if (potentialCallers.length === 0) { 397 | potentialCallers.push({ 398 | filePath: sourceFile, 399 | lineNumber: enclosingSymbol.range.start.line, 400 | code: document.lineAt(enclosingSymbol.range.start.line).text.trim(), 401 | functionName: enclosingSymbol.name, 402 | functionRange: enclosingSymbol.range 403 | }); 404 | } 405 | 406 | } catch (error) { 407 | console.error('Error finding potential callers:', error); 408 | console.error('Stack:', error instanceof Error ? error.stack : ''); 409 | } 410 | 411 | return potentialCallers; 412 | } 413 | 414 | async analyzeCallers( 415 | currentLogLine: string, 416 | staticSearchString: string, 417 | allLogs: RustLogEntry[], 418 | potentialCallers: Array<{ filePath: string; lineNumber: number; code: string; functionName: string; functionRange?: vscode.Range }> 419 | ): Promise { 420 | try { 421 | this.isAnalyzing = true; 422 | this._onDidChangeTreeData.fire(); 423 | 424 | vscode.window.showInformationMessage('Computing call stack analysis...'); 425 | const allLogLines = allLogs.map(log => log.message || '').filter(msg => msg); 426 | 427 | const analysis = await this.claudeService.analyzeCallers( 428 | currentLogLine, 429 | staticSearchString, 430 | allLogLines, 431 | potentialCallers 432 | ); 433 | 434 | // Convert to CallerNode structure 435 | this.callerAnalysis = analysis.rankedCallers.map(rc => ({ 436 | filePath: rc.filePath, 437 | lineNumber: rc.lineNumber, 438 | code: rc.code, 439 | functionName: rc.functionName, 440 | confidence: rc.confidence, 441 | explanation: rc.explanation 442 | })); 443 | 444 | vscode.window.showInformationMessage('Call stack analysis complete'); 445 | this.isAnalyzing = false; 446 | this._onDidChangeTreeData.fire(); 447 | } catch (error) { 448 | console.error('Error analyzing callers:', error); 449 | vscode.window.showErrorMessage('Failed to analyze potential callers'); 450 | this.isAnalyzing = false; 451 | this._onDidChangeTreeData.fire(); 452 | } 453 | } 454 | 455 | /** 456 | * Get the tree item for a given element 457 | */ 458 | getTreeItem(element: CallStackTreeItem): CallStackTreeItem { 459 | return element; 460 | } 461 | 462 | /** 463 | * Get children for a given element 464 | */ 465 | async getChildren(element?: CallStackTreeItem): Promise { 466 | if (!this.currentLogEntry && !element) { 467 | return [new CallStackTreeItem({ 468 | filePath: '', 469 | lineNumber: 0, 470 | code: 'No log selected', 471 | functionName: '', 472 | confidence: 0, 473 | explanation: '' 474 | }, this)]; 475 | } 476 | 477 | if (this.isAnalyzing && !element) { 478 | return [new CallStackTreeItem({ 479 | filePath: '', 480 | lineNumber: 0, 481 | code: 'Computing call stack analysis...', 482 | functionName: '', 483 | confidence: 0, 484 | explanation: 'Please wait while we analyze the call stack' 485 | }, this)]; 486 | } 487 | 488 | if (!element) { 489 | // Root level - show initial callers 490 | if (this.callerAnalysis.length === 0) { 491 | return [new CallStackTreeItem({ 492 | filePath: '', 493 | lineNumber: 0, 494 | code: 'No call stack found', 495 | functionName: '', 496 | confidence: 0, 497 | explanation: 'Could not determine the call stack for this log entry' 498 | }, this)]; 499 | } 500 | return this.callerAnalysis.map(caller => new CallStackTreeItem(caller, this, true)); 501 | } 502 | 503 | // Return children if they exist 504 | return (element.caller.children || []).map(child => new CallStackTreeItem(child, this, true)); 505 | } 506 | 507 | public async openCallStackLocation( 508 | caller: CallerNode, 509 | treeItem: CallStackTreeItem 510 | ): Promise { 511 | try { 512 | // Open the file and reveal the line 513 | const document = await vscode.workspace.openTextDocument(caller.filePath); 514 | const editor = await vscode.window.showTextDocument(document); 515 | const range = new vscode.Range( 516 | caller.lineNumber, 517 | 0, 518 | caller.lineNumber, 519 | document.lineAt(caller.lineNumber).text.length 520 | ); 521 | editor.revealRange(range, vscode.TextEditorRevealType.InCenter); 522 | 523 | // Apply the yellow highlight decoration 524 | editor.setDecorations(logLineDecorationType, [range]); 525 | 526 | // If children haven't been analyzed yet, do it now 527 | if (!caller.children && !caller.isLoading) { 528 | // Check cache first 529 | const cacheKey = `${caller.filePath}:${caller.lineNumber}`; 530 | const cached = this.callStackCache.get(cacheKey); 531 | 532 | if (cached) { 533 | // Use cached results for the immediate children 534 | caller.children = cached.children; 535 | 536 | // Now, recursively load the children of these children from the cache 537 | for (const child of caller.children) { 538 | this.loadChildrenFromCacheRecursive(child); 539 | } 540 | 541 | // Refresh the specific item that was clicked. Since all descendants 542 | // are now loaded in the data model, the tree view should render them. 543 | this._onDidChangeTreeData.fire(treeItem); 544 | return; // Return after handling cache 545 | } 546 | 547 | // Mark as loading (only if not cached) 548 | caller.isLoading = true; 549 | this._onDidChangeTreeData.fire(treeItem); // Refresh item to show loading state 550 | 551 | vscode.window.showInformationMessage('Finding potential callers...'); 552 | // Find potential callers for this location 553 | const potentialCallers = await this.findPotentialCallers( 554 | caller.filePath, 555 | caller.lineNumber 556 | ); 557 | 558 | if (potentialCallers.length > 0) { 559 | vscode.window.showInformationMessage('Analyzing call locations...'); 560 | // Get the code content for analysis 561 | const lineText = document.lineAt(caller.lineNumber).text; 562 | 563 | // Analyze callers 564 | const analysis = await this.claudeService.analyzeCallers( 565 | lineText, 566 | lineText, 567 | [], 568 | potentialCallers 569 | ); 570 | 571 | // Update the caller's children 572 | caller.children = analysis.rankedCallers.map(rc => ({ 573 | filePath: rc.filePath, 574 | lineNumber: rc.lineNumber, 575 | code: rc.code, 576 | functionName: rc.functionName, 577 | confidence: rc.confidence, 578 | explanation: rc.explanation, 579 | children: undefined, 580 | isLoading: false 581 | })); 582 | 583 | // Cache the results (only the direct children) 584 | this.callStackCache.set(cacheKey, { 585 | children: caller.children, // Store only the direct children structure 586 | lastUpdated: new Date().toISOString() 587 | }); 588 | 589 | vscode.window.showInformationMessage('Call location analysis complete'); 590 | } else { 591 | caller.children = []; // Empty array to indicate analysis is complete 592 | 593 | // Cache empty results too 594 | this.callStackCache.set(cacheKey, { 595 | children: [], 596 | lastUpdated: new Date().toISOString() 597 | }); 598 | 599 | vscode.window.showInformationMessage('No potential callers found'); 600 | } 601 | 602 | // Clear loading state 603 | caller.isLoading = false; 604 | this._onDidChangeTreeData.fire(treeItem); 605 | } 606 | } catch (error) { 607 | console.error('Error in openCallStackLocation:', error); 608 | vscode.window.showErrorMessage('Failed to analyze call stack location'); 609 | 610 | // Clear loading state on error 611 | if (caller.isLoading) { 612 | caller.isLoading = false; 613 | caller.children = []; 614 | this._onDidChangeTreeData.fire(treeItem); 615 | } 616 | } 617 | } 618 | 619 | public clearCache(): void { 620 | this.callStackCache.clear(); 621 | } 622 | 623 | private loadChildrenFromCacheRecursive(node: CallerNode): void { 624 | // Base case: If the node already has children defined (even an empty array), 625 | // it means it was either analyzed or already processed by this function. 626 | if (node.children !== undefined) { 627 | // Still need to check descendants even if this node's children are loaded 628 | for (const child of node.children) { 629 | this.loadChildrenFromCacheRecursive(child); 630 | } 631 | return; 632 | } 633 | 634 | const cacheKey = `${node.filePath}:${node.lineNumber}`; 635 | const cached = this.callStackCache.get(cacheKey); 636 | 637 | if (cached) { 638 | // Assign cached children 639 | node.children = cached.children; 640 | // Recursively load children for each newly assigned child 641 | for (const child of node.children) { 642 | this.loadChildrenFromCacheRecursive(child); 643 | } 644 | } else { 645 | // If not in cache, mark children as undefined so it can be analyzed on demand later 646 | node.children = undefined; 647 | } 648 | } 649 | } 650 | 651 | /** 652 | * Register the Call Stack Explorer view and related commands 653 | */ 654 | export function registerCallStackExplorer(context: vscode.ExtensionContext): CallStackExplorerProvider { 655 | // Create the provider 656 | const callStackExplorerProvider = new CallStackExplorerProvider(context); 657 | 658 | // Register the tree view 659 | const treeView = vscode.window.createTreeView('callStackExplorer', { 660 | treeDataProvider: callStackExplorerProvider, 661 | showCollapseAll: true 662 | }); 663 | 664 | // Register command to copy span property value 665 | const copySpanValueCommand = vscode.commands.registerCommand( 666 | 'traceback.copySpanValue', 667 | (item: SpanDetailItem) => { 668 | vscode.env.clipboard.writeText(item.value); 669 | vscode.window.showInformationMessage('Value copied to clipboard'); 670 | } 671 | ); 672 | 673 | // Add to the extension context 674 | context.subscriptions.push( 675 | treeView, 676 | copySpanValueCommand 677 | ); 678 | 679 | return callStackExplorerProvider; 680 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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 |

TraceBack Settings

523 |
524 | 525 |

1. Choose Data Source

526 | 527 |
528 |

Paste Rust Logs

529 | 530 | 531 | 532 |
533 | 534 |
535 |

Upload Log File

536 | 537 |
538 | ${logFilePath ? `Current: ${logFilePath}` : 'No file selected'} 539 |
540 |
541 | 542 |

2. Select Repository

543 |
544 | 545 |
546 | ${repoPath ? `Current: ${repoPath}` : 'No repository selected'} 547 |
548 |
549 | 550 |

3. Add API key

551 |
552 |
553 | 554 | 555 | 556 |
557 | ${this._extensionContext.workspaceState.get('claudeApiKeySet') ? 'API key is set' : 'No API key set'} 558 |
559 |
560 |
561 | 562 |
563 | 564 | 658 | 659 | `; 660 | } 661 | 662 | public dispose() { 663 | SettingsView.currentPanel = undefined; 664 | 665 | // Clean up resources 666 | this._panel.dispose(); 667 | 668 | while (this._disposables.length) { 669 | const x = this._disposables.pop(); 670 | if (x) { 671 | x.dispose(); 672 | } 673 | } 674 | } 675 | } -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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/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 | }; --------------------------------------------------------------------------------