From f07bbaf262f50fb8c66296c9d49b6ca00fc865ba Mon Sep 17 00:00:00 2001 From: Akshay Dubey Date: Sat, 14 Feb 2026 23:08:29 +0530 Subject: [PATCH] new release changes and fixes --- .env.example | 14 + .github/workflows/bump-version.yml | 221 ++++++++++ .github/workflows/ci.yml | 99 +++++ .github/workflows/release.yml | 108 +++++ .gitignore | 59 +++ CHANGELOG.md | 87 ++++ LICENSE | 21 + MANIFEST.in | 36 ++ VERSION_BUMPING.md | 291 ++++++++++++ bump_version.py | 111 +++++ docs/README.md | 200 +++++++++ docs/changelog.md | 7 + docs/getting-started/configuration.md | 221 ++++++++++ docs/getting-started/installation.md | 86 ++++ docs/getting-started/quickstart.md | 139 ++++++ docs/guide/commands.md | 335 ++++++++++++++ docs/index.md | 115 +++++ pyproject.toml | 186 ++++++++ sample_logs/api-server.log | 23 + sample_logs/database.log | 15 + sample_logs/redis.log | 9 + src/debugai/__init__.py | 63 +++ src/debugai/ai/__init__.py | 5 + src/debugai/ai/embeddings.py | 157 +++++++ src/debugai/ai/gemini_client.py | 390 ++++++++++++++++ src/debugai/ai/prompts.py | 188 ++++++++ src/debugai/analysis/__init__.py | 6 + src/debugai/analysis/correlator.py | 276 ++++++++++++ src/debugai/analysis/timeline_builder.py | 239 ++++++++++ src/debugai/cli/__init__.py | 1 + src/debugai/cli/commands/__init__.py | 1 + src/debugai/cli/commands/analyze.py | 539 +++++++++++++++++++++++ src/debugai/cli/commands/config.py | 144 ++++++ src/debugai/cli/commands/explain.py | 127 ++++++ src/debugai/cli/commands/interactive.py | 127 ++++++ src/debugai/cli/commands/logs.py | 169 +++++++ src/debugai/cli/commands/suggest.py | 134 ++++++ src/debugai/cli/commands/timeline.py | 158 +++++++ src/debugai/cli/help_theme.py | 193 ++++++++ src/debugai/cli/main.py | 433 ++++++++++++++++++ src/debugai/config/__init__.py | 5 + src/debugai/config/settings.py | 266 +++++++++++ src/debugai/core/__init__.py | 6 + src/debugai/core/analyzer.py | 182 ++++++++ src/debugai/core/doctor.py | 160 +++++++ src/debugai/core/engine.py | 179 ++++++++ src/debugai/core/initializer.py | 126 ++++++ src/debugai/core/status.py | 115 +++++ src/debugai/ingestion/__init__.py | 8 + src/debugai/ingestion/docker_ingester.py | 196 +++++++++ src/debugai/ingestion/file_ingester.py | 240 ++++++++++ src/debugai/ingestion/parser.py | 214 +++++++++ src/debugai/ingestion/stream_ingester.py | 218 +++++++++ src/debugai/storage/__init__.py | 5 + src/debugai/storage/database.py | 355 +++++++++++++++ src/debugai/ui/__init__.py | 31 ++ src/debugai/ui/effects.py | 503 +++++++++++++++++++++ tests/stress_test.py | 0 58 files changed, 8542 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/bump-version.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 VERSION_BUMPING.md create mode 100644 bump_version.py create mode 100644 docs/README.md create mode 100644 docs/changelog.md create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/quickstart.md create mode 100644 docs/guide/commands.md create mode 100644 docs/index.md create mode 100644 pyproject.toml create mode 100644 sample_logs/api-server.log create mode 100644 sample_logs/database.log create mode 100644 sample_logs/redis.log create mode 100644 src/debugai/__init__.py create mode 100644 src/debugai/ai/__init__.py create mode 100644 src/debugai/ai/embeddings.py create mode 100644 src/debugai/ai/gemini_client.py create mode 100644 src/debugai/ai/prompts.py create mode 100644 src/debugai/analysis/__init__.py create mode 100644 src/debugai/analysis/correlator.py create mode 100644 src/debugai/analysis/timeline_builder.py create mode 100644 src/debugai/cli/__init__.py create mode 100644 src/debugai/cli/commands/__init__.py create mode 100644 src/debugai/cli/commands/analyze.py create mode 100644 src/debugai/cli/commands/config.py create mode 100644 src/debugai/cli/commands/explain.py create mode 100644 src/debugai/cli/commands/interactive.py create mode 100644 src/debugai/cli/commands/logs.py create mode 100644 src/debugai/cli/commands/suggest.py create mode 100644 src/debugai/cli/commands/timeline.py create mode 100644 src/debugai/cli/help_theme.py create mode 100644 src/debugai/cli/main.py create mode 100644 src/debugai/config/__init__.py create mode 100644 src/debugai/config/settings.py create mode 100644 src/debugai/core/__init__.py create mode 100644 src/debugai/core/analyzer.py create mode 100644 src/debugai/core/doctor.py create mode 100644 src/debugai/core/engine.py create mode 100644 src/debugai/core/initializer.py create mode 100644 src/debugai/core/status.py create mode 100644 src/debugai/ingestion/__init__.py create mode 100644 src/debugai/ingestion/docker_ingester.py create mode 100644 src/debugai/ingestion/file_ingester.py create mode 100644 src/debugai/ingestion/parser.py create mode 100644 src/debugai/ingestion/stream_ingester.py create mode 100644 src/debugai/storage/__init__.py create mode 100644 src/debugai/storage/database.py create mode 100644 src/debugai/ui/__init__.py create mode 100644 src/debugai/ui/effects.py create mode 100644 tests/stress_test.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1e6fb5 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# DebugAI Environment Variables +# Copy this file to .env and fill in your values + +# Required: Gemini API Key +# Get yours at: https://makersuite.google.com/app/apikey +GEMINI_API_KEY=your_api_key_here + +# Optional: Model selection +# Options: gemini-1.5-flash (default), gemini-1.5-pro +DEBUGAI_MODEL=gemini-1.5-flash + +# Optional: Output format +# Options: rich (default), json, markdown, plain +DEBUGAI_FORMAT=rich diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..8eaae31 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,221 @@ +name: Bump Version and Release + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + type: choice + options: + - major + - minor + - patch + default: 'patch' + +permissions: + contents: write + id-token: write + +jobs: + bump-version: + name: Bump Version + runs-on: ubuntu-latest + + outputs: + new_version: ${{ steps.bump.outputs.new_version }} + old_version: ${{ steps.get_version.outputs.current_version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Get current version + id: get_version + run: | + CURRENT_VERSION=$(grep -oP '(?<=version = ")[^"]*' debugai/pyproject.toml) + echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Bump version + id: bump + run: | + CURRENT_VERSION="${{ steps.get_version.outputs.current_version }}" + VERSION_TYPE="${{ github.event.inputs.version_type }}" + + # Split version into parts + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + + # Bump based on type + case $VERSION_TYPE in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION" + + - name: Update pyproject.toml + run: | + sed -i 's/version = "${{ steps.get_version.outputs.current_version }}"/version = "${{ steps.bump.outputs.new_version }}"/' debugai/pyproject.toml + + - name: Update CHANGELOG.md + run: | + NEW_VERSION="${{ steps.bump.outputs.new_version }}" + TODAY=$(date +%Y-%m-%d) + VERSION_TYPE="${{ github.event.inputs.version_type }}" + + # Create changelog entry + cat > /tmp/changelog_entry.md << EOF + ## [$NEW_VERSION] - $TODAY + + ### Changed + - Version bump: $VERSION_TYPE release + + EOF + + # Insert after [Unreleased] section + sed -i "/## \[Unreleased\]/r /tmp/changelog_entry.md" debugai/CHANGELOG.md + + - name: Commit version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add debugai/pyproject.toml debugai/CHANGELOG.md + git commit -m "Bump version to ${{ steps.bump.outputs.new_version }}" + git push origin ${{ github.ref_name }} + + - name: Create and push tag + run: | + git tag -a "v${{ steps.bump.outputs.new_version }}" -m "Release v${{ steps.bump.outputs.new_version }}" + git push origin "v${{ steps.bump.outputs.new_version }}" + + # Trigger the release workflow by creating the tag + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: bump-version + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: v${{ needs.bump-version.outputs.new_version }} + fetch-depth: 0 + + - name: Extract changelog for this version + id: changelog + run: | + VERSION=${{ needs.bump-version.outputs.new_version }} + CHANGELOG=$(sed -n "/## \[$VERSION\]/,/## \[/p" debugai/CHANGELOG.md | sed '$d') + echo "CHANGELOG<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ needs.bump-version.outputs.new_version }} + name: Release v${{ needs.bump-version.outputs.new_version }} + body: | + ## What's Changed + + ${{ steps.changelog.outputs.CHANGELOG }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/v${{ needs.bump-version.outputs.old_version }}...v${{ needs.bump-version.outputs.new_version }} + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Build and publish to PyPI + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [bump-version, create-release] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: v${{ needs.bump-version.outputs.new_version }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine hatchling + + - name: Build package + run: | + cd debugai + python -m build + + - name: Check distribution + run: | + cd debugai + twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: debugai/dist/ + skip-existing: true + + # Build and publish documentation + publish-docs: + name: Publish Documentation + runs-on: ubuntu-latest + needs: [bump-version, create-release] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: v${{ needs.bump-version.outputs.new_version }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd debugai + pip install -e ".[docs]" + + - name: Build documentation + run: | + cd debugai + mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: debugai/site diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a6300a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.11', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + pytest tests/ -v --cov=debugai --cov-report=xml --cov-report=term + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-${{ matrix.os }}-py${{ matrix.python-version }} + + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install ruff black mypy + + - name: Run Ruff + run: ruff check . + + - name: Run Black + run: black --check . + + - name: Run MyPy + run: mypy src/debugai --ignore-missing-imports + + stress-test: + name: Run Stress Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -e ".[dev]" + + - name: Run stress tests + run: | + python tests/stress_test.py + + - name: Upload stress test results + uses: actions/upload-artifact@v3 + if: always() + with: + name: stress-test-results + path: | + stress-test-*.log + stress-test-*.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..93b3384 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release & Publish + +on: + push: + tags: + - 'v*.*.*' # Trigger on version tags like v1.0.0 + +permissions: + contents: write + id-token: write + +jobs: + # Create GitHub Release + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Extract changelog + id: changelog + run: | + # Extract changelog for this version + VERSION=${{ steps.get_version.outputs.VERSION }} + CHANGELOG=$(sed -n "/## \[$VERSION\]/,/## \[/p" CHANGELOG.md | sed '$d') + echo "CHANGELOG<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: Release v${{ steps.get_version.outputs.VERSION }} + body: ${{ steps.changelog.outputs.CHANGELOG }} + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Build and publish to PyPI + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: create-release + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine hatchling + + - name: Build package + run: python -m build + + - name: Check distribution + run: twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + # Build and publish documentation + publish-docs: + name: Publish Documentation + runs-on: ubuntu-latest + needs: create-release + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -e ".[docs]" + + - name: Build documentation + run: mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + cname: debugai.dev # Optional: your custom domain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4f7cfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# DebugAI Git Ignore + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Type checking +.mypy_cache/ + +# DebugAI specific +.debugai/cache/ +.debugai/*.db +.debugai/*.log + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..29d0e7a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Planned +- WebSocket support for real-time log streaming +- Support for Elasticsearch integration +- Custom plugin system +- Web dashboard + +## [1.0.0] - 2026-02-14 + +### Added +- šŸŽ‰ Initial release of DebugAI +- AI-powered log analysis using Google Gemini +- Cross-service error correlation engine +- Timeline generation for event sequences +- Docker container log integration +- Real-time log streaming and analysis +- 8 beautiful CLI themes (Cyan, Green, Red, Purple, Orange, Blue, Matrix, Hacker) +- Interactive debugging mode +- Comprehensive stress testing suite +- Support for multiple log formats (JSON, Apache, Nginx, Syslog) + +### Features +- **Log Analysis** + - Parse logs from files, directories, and Docker containers + - Detect error patterns automatically + - Categorize errors by type and severity + - Generate detailed statistics + +- **AI Capabilities** + - Plain English error explanations + - Smart fix suggestions with confidence scores + - Context-aware analysis + - Error correlation across services + +- **CLI Interface** + - Rich, colorful terminal output + - Multiple export formats (Rich, JSON, Markdown) + - Progress indicators and animations + - Customizable themes + +- **Developer Tools** + - Comprehensive API documentation + - Type hints throughout codebase + - Unit and stress tests + - Docker support + +### Documentation +- Complete user guide +- API reference documentation +- Quick start tutorial +- Configuration examples + +### Dependencies +- Python 3.11+ support +- Google Generative AI SDK +- Typer for CLI framework +- Rich for terminal formatting +- SQLAlchemy for storage +- Docker SDK for container integration + +--- + +## Release Process + +To create a new release: + +1. Update version in `pyproject.toml` +2. Update this CHANGELOG.md +3. Commit changes: `git commit -m "Release vX.Y.Z"` +4. Create tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"` +5. Push: `git push origin main --tags` +6. GitHub Actions will automatically: + - Create GitHub release + - Build and publish to PyPI + - Deploy documentation + +## Version History + +- **v1.0.0** (2026-02-14) - Initial public release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c8ba6d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 DebugAI Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..985ea12 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,36 @@ +# Manifest for package distribution +# Includes non-Python files in the package + +include LICENSE +include README.md +include CHANGELOG.md +include CONTRIBUTING.md +include SECURITY.md +include pyproject.toml +include mkdocs.yml + +# Include documentation +recursive-include docs *.md +recursive-include docs *.png +recursive-include docs *.jpg +recursive-include docs *.svg + +# Include sample files +recursive-include sample_logs *.log + +# Include batch scripts +include run_stress_test.bat +include run_ui_demo.bat + +# Include config examples +include .env.example +recursive-include .debugai *.yaml +recursive-include .debugai .gitignore + +# Exclude unnecessary files +exclude .gitignore +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] +recursive-exclude * .DS_Store +recursive-exclude tests * +recursive-exclude .github * diff --git a/VERSION_BUMPING.md b/VERSION_BUMPING.md new file mode 100644 index 0000000..a3a7cfd --- /dev/null +++ b/VERSION_BUMPING.md @@ -0,0 +1,291 @@ +# Version Bumping Guide + +DebugAI supports automatic version bumping with three options: **major**, **minor**, and **patch** (bugfix). + +## Version Format + +We follow [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH): + +``` +MAJOR.MINOR.PATCH + │ │ │ + │ │ └─── Bug fixes, backwards compatible (0.0.1 → 0.0.2) + │ └───────── New features, backwards compatible (0.0.0 → 0.1.0) + └─────────────── Breaking changes (0.0.0 → 1.0.0) +``` + +### Examples: + +| Current | Bump Type | New Version | Use Case | +|---------|-----------|-------------|----------| +| 1.0.0 | **major** | 2.0.0 | Breaking changes, major new features | +| 1.0.0 | **minor** | 1.1.0 | New features, backwards compatible | +| 1.0.0 | **patch** | 1.0.1 | Bug fixes, small improvements | + +--- + +## šŸš€ Method 1: GitHub Actions (Recommended) + +### Automated Version Bump via GitHub UI + +1. **Go to your repository on GitHub** +2. **Click on "Actions" tab** +3. **Select "Bump Version and Release" workflow** +4. **Click "Run workflow" dropdown** +5. **Select version type:** + - `major` - Breaking changes (1.0.0 → 2.0.0) + - `minor` - New features (1.0.0 → 1.1.0) + - `patch` - Bug fixes (1.0.0 → 1.0.1) +6. **Click "Run workflow"** + +### What Happens Automatically: + +1. āœ… Bumps version in `pyproject.toml` +2. āœ… Updates `CHANGELOG.md` with new version +3. āœ… Commits changes +4. āœ… Creates git tag (e.g., `v1.0.1`) +5. āœ… Creates GitHub Release +6. āœ… Publishes to PyPI +7. āœ… Deploys documentation + +**That's it! Everything is automated!** šŸŽ‰ + +--- + +## šŸ› ļø Method 2: Local Python Script + +### Using the bump_version.py script + +```bash +# Navigate to debugai directory +cd debugai + +# Bump major version (1.0.0 → 2.0.0) +python bump_version.py major + +# Bump minor version (1.0.0 → 1.1.0) +python bump_version.py minor + +# Bump patch version (1.0.0 → 1.0.1) +python bump_version.py patch +``` + +The script will: +1. Show current and new version +2. Ask for confirmation +3. Update `pyproject.toml` +4. Update `CHANGELOG.md` +5. Show next steps + +**Then manually:** +```bash +git add pyproject.toml CHANGELOG.md +git commit -m "Bump version to X.Y.Z" +git push origin main +git tag -a vX.Y.Z -m "Release vX.Y.Z" +git push origin vX.Y.Z +``` + +--- + +## šŸ“ Method 3: Manual Version Bump + +### Step-by-Step Manual Process + +1. **Edit `pyproject.toml`** + ```toml + [project] + version = "1.0.1" # Change this + ``` + +2. **Edit `CHANGELOG.md`** + ```markdown + ## [1.0.1] - 2026-02-14 + + ### Fixed + - Bug fix description + + ### Added + - New feature description + ``` + +3. **Commit changes** + ```bash + git add pyproject.toml CHANGELOG.md + git commit -m "Bump version to 1.0.1" + git push origin main + ``` + +4. **Create tag** + ```bash + git tag -a v1.0.1 -m "Release v1.0.1" + git push origin v1.0.1 + ``` + +The tag triggers automatic release workflow! šŸš€ + +--- + +## šŸ“Š When to Use Each Version Type + +### šŸ”“ Major Version (X.0.0) + +**Use when:** +- Breaking API changes +- Removing features +- Major architectural changes +- Incompatible with previous versions + +**Examples:** +``` +1.5.3 → 2.0.0 +- Removed deprecated `analyze_text()` function +- Changed CLI argument structure +- Requires Python 3.12+ instead of 3.11+ +``` + +### 🟔 Minor Version (0.X.0) + +**Use when:** +- Adding new features +- New functionality (backwards compatible) +- Deprecating features (but not removing) +- Significant improvements + +**Examples:** +``` +1.0.5 → 1.1.0 +- Added support for Elasticsearch logs +- New command: debugai export +- Added 3 new themes +``` + +### 🟢 Patch Version (0.0.X) + +**Use when:** +- Bug fixes +- Performance improvements +- Documentation updates +- Security patches +- Small tweaks + +**Examples:** +``` +1.1.0 → 1.1.1 +- Fixed NullPointerException in parser +- Improved error messages +- Updated dependencies +- Fixed typos in docs +``` + +--- + +## šŸ”„ Complete Release Workflow + +### Quick Release (GitHub Actions) + +```bash +# 1. Make your changes and commit +git add . +git commit -m "Add new feature X" +git push origin main + +# 2. Go to GitHub → Actions → "Bump Version and Release" +# 3. Select version type and click "Run workflow" +# 4. Done! āœ… +``` + +### Full Manual Release + +```bash +# 1. Make changes +git add . +git commit -m "Add feature X" + +# 2. Bump version +python bump_version.py minor + +# 3. Review changes +git diff + +# 4. Commit version bump +git add pyproject.toml CHANGELOG.md +git commit -m "Bump version to 1.1.0" + +# 5. Tag and push +git tag -a v1.1.0 -m "Release v1.1.0" +git push origin main --tags + +# 6. GitHub Actions does the rest! +``` + +--- + +## šŸŽÆ Best Practices + +1. āœ… **Always update CHANGELOG.md** with what changed +2. āœ… **Test before releasing** - run tests locally +3. āœ… **Use semantic versioning** correctly +4. āœ… **Write clear commit messages** +5. āœ… **Tag releases** with annotated tags (`-a`) +6. āœ… **Review GitHub Release** after automation completes + +--- + +## šŸ› Troubleshooting + +### Version bump failed + +**Check:** +- Are you on the main branch? +- Do you have latest changes pulled? +- Is the version in pyproject.toml valid? + +### Tag already exists + +```bash +# Delete local tag +git tag -d v1.0.1 + +# Delete remote tag +git push origin :refs/tags/v1.0.1 + +# Create new tag +git tag -a v1.0.1 -m "Release v1.0.1" +git push origin v1.0.1 +``` + +### PyPI upload failed + +**Check:** +- Is PYPI_API_TOKEN set in GitHub Secrets? +- Has this version already been published? +- You cannot re-upload the same version to PyPI + +--- + +## šŸ“š Resources + +- [Semantic Versioning](https://semver.org/) +- [Keep a Changelog](https://keepachangelog.com/) +- [GitHub Actions Documentation](https://docs.github.com/en/actions) + +--- + +## šŸš€ Quick Reference + +```bash +# GitHub Actions (Automated) - RECOMMENDED +Actions → Bump Version and Release → Select type → Run + +# Local Script +python bump_version.py major|minor|patch + +# Manual +# 1. Edit pyproject.toml and CHANGELOG.md +# 2. git commit -m "Bump version to X.Y.Z" +# 3. git tag -a vX.Y.Z -m "Release vX.Y.Z" +# 4. git push origin main --tags +``` + +**Remember:** Once you push a tag starting with `v`, everything else is automatic! šŸŽ‰ diff --git a/bump_version.py b/bump_version.py new file mode 100644 index 0000000..b8df018 --- /dev/null +++ b/bump_version.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Version Bump Script for DebugAI + +Usage: + python bump_version.py major # 1.0.0 -> 2.0.0 + python bump_version.py minor # 1.0.0 -> 1.1.0 + python bump_version.py patch # 1.0.0 -> 1.0.1 +""" + +import sys +import re +from pathlib import Path +from datetime import datetime + + +def get_current_version(pyproject_path: Path) -> str: + """Extract current version from pyproject.toml""" + content = pyproject_path.read_text() + match = re.search(r'version = "([^"]+)"', content) + if not match: + raise ValueError("Could not find version in pyproject.toml") + return match.group(1) + + +def bump_version(current_version: str, bump_type: str) -> str: + """Bump version based on type""" + major, minor, patch = map(int, current_version.split('.')) + + if bump_type == 'major': + major += 1 + minor = 0 + patch = 0 + elif bump_type == 'minor': + minor += 1 + patch = 0 + elif bump_type == 'patch': + patch += 1 + else: + raise ValueError(f"Invalid bump type: {bump_type}") + + return f"{major}.{minor}.{patch}" + + +def update_pyproject(pyproject_path: Path, old_version: str, new_version: str): + """Update version in pyproject.toml""" + content = pyproject_path.read_text() + content = content.replace(f'version = "{old_version}"', f'version = "{new_version}"') + pyproject_path.write_text(content) + print(f"āœ“ Updated {pyproject_path}") + + +def update_changelog(changelog_path: Path, new_version: str, bump_type: str): + """Add entry to CHANGELOG.md""" + content = changelog_path.read_text() + today = datetime.now().strftime('%Y-%m-%d') + + new_entry = f""" +## [{new_version}] - {today} + +### Changed +- Version bump: {bump_type} release + +""" + + # Insert after [Unreleased] section + content = content.replace('## [Unreleased]', f'## [Unreleased]\n{new_entry}') + changelog_path.write_text(content) + print(f"āœ“ Updated {changelog_path}") + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in ['major', 'minor', 'patch']: + print(__doc__) + sys.exit(1) + + bump_type = sys.argv[1] + + # Paths + base_dir = Path(__file__).parent + pyproject_path = base_dir / 'pyproject.toml' + changelog_path = base_dir / 'CHANGELOG.md' + + # Get current version + current_version = get_current_version(pyproject_path) + print(f"Current version: {current_version}") + + # Calculate new version + new_version = bump_version(current_version, bump_type) + print(f"New version: {new_version}") + + # Confirm + response = input(f"\nBump version from {current_version} to {new_version}? [y/N]: ") + if response.lower() != 'y': + print("Cancelled") + sys.exit(0) + + # Update files + update_pyproject(pyproject_path, current_version, new_version) + update_changelog(changelog_path, new_version, bump_type) + + print(f"\nāœ… Version bumped to {new_version}") + print("\nNext steps:") + print(f" git add pyproject.toml CHANGELOG.md") + print(f" git commit -m 'Bump version to {new_version}'") + print(f" git tag -a v{new_version} -m 'Release v{new_version}'") + print(f" git push origin main --tags") + + +if __name__ == '__main__': + main() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..240f5e2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,200 @@ +# Building Documentation + +DebugAI uses [MkDocs](https://www.mkdocs.org/) with the [Material](https://squidfunk.github.io/mkdocs-material/) theme for documentation. + +## Local Development + +### Install Dependencies + +```bash +pip install -e ".[docs]" +``` + +### Serve Locally + +```bash +mkdocs serve +``` + +Visit http://127.0.0.1:8000 in your browser. + +The site will automatically reload when you make changes to the documentation files. + +### Build Documentation + +```bash +mkdocs build +``` + +This creates a `site/` directory with the static website. + +## Documentation Structure + +``` +docs/ +ā”œā”€ā”€ index.md # Homepage +ā”œā”€ā”€ getting-started/ +│ ā”œā”€ā”€ installation.md # Installation guide +│ ā”œā”€ā”€ quickstart.md # Quick start tutorial +│ └── configuration.md # Configuration reference +ā”œā”€ā”€ guide/ +│ ā”œā”€ā”€ commands.md # Commands reference +│ ā”œā”€ā”€ analysis.md # Log analysis guide +│ ā”œā”€ā”€ correlation.md # Error correlation +│ ā”œā”€ā”€ ai-features.md # AI capabilities +│ ā”œā”€ā”€ docker.md # Docker integration +│ └── themes.md # Themes guide +ā”œā”€ā”€ api/ +│ ā”œā”€ā”€ parser.md # Parser API +│ ā”œā”€ā”€ analyzer.md # Analyzer API +│ ā”œā”€ā”€ ai-client.md # AI Client API +│ └── database.md # Database API +ā”œā”€ā”€ development/ +│ ā”œā”€ā”€ contributing.md # Contributing guide +│ ā”œā”€ā”€ architecture.md # Architecture overview +│ ā”œā”€ā”€ testing.md # Testing guide +│ └── release.md # Release process +ā”œā”€ā”€ faq.md # FAQ +└── changelog.md # Changelog +``` + +## Writing Documentation + +### Markdown Files + +Use standard Markdown with extensions: + +````markdown +# Page Title + +## Section + +Regular text with **bold** and *italic*. + +### Code Blocks + +```python +import debugai + +# Code example +analyzer = debugai.LogAnalyzer() +``` + +### Admonitions + +!!! note "Optional Title" + This is a note + +!!! tip + Helpful tip + +!!! warning + Warning message + +!!! danger + Danger alert + +### Tables + +| Column 1 | Column 2 | +|----------|----------| +| Value 1 | Value 2 | + +### Links + +[Link text](page.md) +[External](https://example.com) +```` + +### API Documentation + +Use mkdocstrings for auto-generated API docs: + +````markdown +::: debugai.parser.LogParser + options: + show_source: true + members: + - parse + - parse_file +```` + +## Deployment + +### Automatic (Recommended) + +Documentation is automatically deployed to GitHub Pages when you create a release tag. + +### Manual + +```bash +# Build +mkdocs build + +# Deploy to GitHub Pages +mkdocs gh-deploy +``` + +## Preview Before Merging + +```bash +# Install docs dependencies +pip install -e ".[docs]" + +# Serve locally +mkdocs serve + +# Check for broken links +mkdocs build --strict +``` + +## Style Guide + +### Tone +- Clear and concise +- Friendly and helpful +- Assume beginner level unless in advanced sections + +### Code Examples +- Always test code examples +- Include imports +- Show expected output when relevant +- Use real-world examples + +### Screenshots +- Use PNG format +- Optimize images (<200KB) +- Place in `docs/images/` +- Use descriptive filenames + +### Headers +- Use sentence case +- Don't skip header levels +- Keep headers descriptive + +### Links +- Use relative links for internal pages +- Full URLs for external links +- Add `target="_blank"` for external links + +## Troubleshooting + +### Port Already in Use + +```bash +mkdocs serve -a 127.0.0.1:8001 +``` + +### Theme Not Loading + +```bash +pip install --upgrade mkdocs-material +``` + +### Broken Links + +```bash +mkdocs build --strict +``` + +This will fail if there are broken links. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..e604e98 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,7 @@ +# Changelog + +All content from CHANGELOG.md is displayed here automatically when the documentation is built. + +--- + +{{ "{!../CHANGELOG.md!}" }} diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..5ebc56d --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,221 @@ +# Configuration + +DebugAI can be configured through multiple methods: + +1. Configuration file (`.debugai/config.yaml`) +2. Environment variables +3. Command-line arguments +4. Interactive CLI + +## Configuration File + +When you run `debugai init`, a configuration file is created at `.debugai/config.yaml`: + +```yaml +# DebugAI Configuration + +# AI Settings +ai: + provider: gemini + api_key: ${GEMINI_API_KEY} # Use environment variable + model: gemini-1.5-pro + temperature: 0.3 + max_tokens: 2048 + +# Analysis Settings +analysis: + enable_correlation: true + correlation_window: 300 # seconds + min_confidence: 0.7 + max_suggestions: 3 + +# UI Settings +ui: + theme: cyan + show_progress: true + animations: true + +# Storage Settings +storage: + database_path: .debugai/debugai.db + cache_enabled: true + cache_ttl: 3600 # seconds + +# Log Parsing +parser: + timezone: UTC + date_formats: + - "%Y-%m-%d %H:%M:%S" + - "%Y-%m-%dT%H:%M:%S" + - "%d/%b/%Y:%H:%M:%S" +``` + +## Environment Variables + +Override configuration with environment variables: + +```bash +# AI Configuration +export GEMINI_API_KEY=your_api_key +export DEBUGAI_AI_MODEL=gemini-1.5-flash +export DEBUGAI_AI_TEMPERATURE=0.5 + +# Analysis +export DEBUGAI_ENABLE_CORRELATION=true +export DEBUGAI_MIN_CONFIDENCE=0.8 + +# UI +export DEBUGAI_THEME=matrix +export DEBUGAI_ANIMATIONS=false + +# Storage +export DEBUGAI_DB_PATH=/custom/path/debugai.db +``` + +## CLI Configuration Commands + +### View Current Configuration + +```bash +debugai config show +``` + +### Set Values + +```bash +# Set API key +debugai config set api-key YOUR_KEY + +# Set theme +debugai config set theme hacker + +# Set AI model +debugai config set ai-model gemini-1.5-flash + +# Enable/disable correlation +debugai config set correlation true +``` + +### List Options + +```bash +# List all themes +debugai config list-themes + +# List all AI models +debugai config list-models +``` + +### Reset to Defaults + +```bash +debugai config reset +``` + +## Advanced Configuration + +### Custom Log Formats + +Add custom log format patterns: + +```yaml +parser: + custom_patterns: + - name: "my_app" + pattern: '(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\[(?P\w+)\]\s+(?P.*)' + timestamp_format: "%Y-%m-%d %H:%M:%S" +``` + +### Correlation Rules + +Define custom correlation rules: + +```yaml +analysis: + correlation_rules: + - name: "db_connection_timeout" + pattern: "connection.*timeout" + related_services: ["database", "api"] + window: 60 # seconds +``` + +### AI Prompts + +Customize AI prompts: + +```yaml +ai: + prompts: + explain: "Explain this error in simple terms: {error}" + suggest_fix: "Suggest fixes for: {error}" + correlate: "Find correlations between these errors: {errors}" +``` + +## Per-Project Configuration + +You can have different configurations for different projects: + +```bash +# Initialize in project directory +cd /path/to/project +debugai init + +# This creates .debugai/config.yaml in the current directory +``` + +DebugAI will use the nearest `.debugai/config.yaml` file in the directory tree. + +## Configuration Priority + +Configuration is loaded in this order (highest priority first): + +1. Command-line arguments +2. Environment variables +3. Project configuration (`.debugai/config.yaml`) +4. User configuration (`~/.debugai/config.yaml`) +5. Default values + +## Example Configurations + +### Minimal Setup + +```yaml +ai: + api_key: ${GEMINI_API_KEY} +``` + +### Performance-Optimized + +```yaml +ai: + model: gemini-1.5-flash # Faster model + max_tokens: 1024 + +analysis: + enable_correlation: false # Skip correlation for speed + +storage: + cache_enabled: true + cache_ttl: 7200 +``` + +### Maximum Accuracy + +```yaml +ai: + model: gemini-1.5-pro + temperature: 0.1 # More deterministic + max_tokens: 4096 + +analysis: + enable_correlation: true + correlation_window: 600 + min_confidence: 0.9 + max_suggestions: 5 +``` + +## Next Steps + +- [Learn about commands](../guide/commands.md) +- [Explore AI features](../guide/ai-features.md) +- [API Reference](../api/parser.md) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..74ac282 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,86 @@ +# Installation + +## Requirements + +- Python 3.11 or higher +- pip package manager +- Google Gemini API key (free tier available) + +## Install from PyPI + +The easiest way to install DebugAI is via pip: + +```bash +pip install debugai +``` + +To verify the installation: + +```bash +debugai --version +``` + +## Install from Source + +For the latest development version: + +```bash +# Clone the repository +git clone https://github.com/akshaydubey05/debug-ai.git +cd debug-ai/debugai + +# Install in editable mode with dev dependencies +pip install -e ".[dev]" +``` + +## Install with Optional Dependencies + +### Documentation Tools + +```bash +pip install debugai[docs] +``` + +### Development Tools + +```bash +pip install debugai[dev] +``` + +This includes: +- pytest (testing) +- ruff (linting) +- black (formatting) +- mypy (type checking) +- pre-commit (git hooks) + +## Docker Installation + +Run DebugAI in a Docker container: + +```bash +docker pull debugai/debugai:latest + +docker run -it --rm \ + -v $(pwd)/logs:/logs \ + -e GEMINI_API_KEY=your_api_key \ + debugai/debugai analyze path /logs +``` + +## Get Gemini API Key + +DebugAI uses Google's Gemini AI for intelligent analysis. Get your free API key: + +1. Visit [Google AI Studio](https://makersuite.google.com/app/apikey) +2. Sign in with your Google account +3. Click "Create API Key" +4. Copy your API key + +!!! tip "Free Tier" + The Gemini API offers a generous free tier with 60 requests per minute - perfect for most debugging workflows! + +## Next Steps + +- [Quick Start Guide](quickstart.md) +- [Configuration](configuration.md) +- [Commands Overview](../guide/commands.md) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..6e28128 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,139 @@ +# Quick Start + +Get started with DebugAI in just a few minutes! + +## 1. Initialize DebugAI + +After installation, initialize DebugAI in your project: + +```bash +debugai init +``` + +This creates a `.debugai` directory with default configuration. + +## 2. Configure API Key + +Set your Gemini API key: + +```bash +debugai config set api-key YOUR_GEMINI_API_KEY +``` + +Or use an environment variable: + +```bash +export GEMINI_API_KEY=your_api_key +``` + +## 3. Analyze Your First Logs + +### From Files + +```bash +# Analyze all logs in a directory +debugai analyze path ./logs + +# Analyze with filters +debugai analyze path ./logs --service api --level error +``` + +### From Docker + +```bash +# Analyze a container's logs +debugai analyze docker my-container + +# Analyze multiple containers +debugai analyze docker api db redis --tail 1000 +``` + +### Real-time Streaming + +```bash +# Stream and analyze a log file +debugai analyze stream /var/log/app.log + +# From stdin +tail -f app.log | debugai analyze stream stdin +``` + +## 4. Understand Errors + +Get plain English explanations: + +```bash +debugai explain text "NullPointerException in UserService.getUser()" +``` + +## 5. Get Fix Suggestions + +```bash +debugai suggest-fix text "ModuleNotFoundError: No module named 'requests'" +``` + +## 6. View Timeline + +See what happened before a crash: + +```bash +debugai timeline show --last 1h --filter errors +``` + +## Example Workflow + +Here's a complete debugging workflow: + +```bash +# 1. Analyze logs from your application +debugai analyze path ./logs --service api,db + +# 2. Get detailed explanation of a specific error +debugai explain error err_abc123 + +# 3. Get AI-powered fix suggestions +debugai suggest-fix error err_abc123 + +# 4. View the timeline of events +debugai timeline show --error err_abc123 + +# 5. Export the analysis +debugai analyze path ./logs --format json --save report.json +``` + +## Interactive Mode + +For an interactive experience: + +```bash +debugai interactive +``` + +This starts an interactive session where you can: +- āœ… Analyze logs step by step +- āœ… Ask questions about errors +- āœ… Get real-time suggestions +- āœ… Explore correlations + +## Themes + +DebugAI comes with 8 beautiful themes: + +```bash +# List available themes +debugai config list-themes + +# Set a theme +debugai config set theme matrix + +# Try different themes +debugai config set theme hacker +debugai config set theme purple +``` + +## Next Steps + +- [Learn about all commands](../guide/commands.md) +- [Explore AI features](../guide/ai-features.md) +- [Configure DebugAI](configuration.md) +- [Docker integration](../guide/docker.md) diff --git a/docs/guide/commands.md b/docs/guide/commands.md new file mode 100644 index 0000000..f0ce0b9 --- /dev/null +++ b/docs/guide/commands.md @@ -0,0 +1,335 @@ +# Commands Reference + +Complete reference for all DebugAI commands. + +## debugai analyze + +Analyze logs from various sources. + +### analyze path + +Analyze logs from files or directories. + +```bash +debugai analyze path [OPTIONS] +``` + +**Arguments:** +- `path`: Path to log file or directory + +**Options:** +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--service` | `-s` | Filter by service names (comma-separated) | all | +| `--level` | `-l` | Filter by log level | all | +| `--since` | | Analyze logs since time (e.g., "1h", "30m") | all time | +| `--until` | | Analyze logs until time | now | +| `--correlate/--no-correlate` | | Enable/disable correlation | true | +| `--ai/--no-ai` | | Enable/disable AI analysis | true | +| `--format` | `-f` | Output format (rich, json, markdown) | rich | +| `--save` | `-o` | Save report to file | - | + +**Examples:** + +```bash +# Analyze all logs in directory +debugai analyze path ./logs + +# Filter by service and level +debugai analyze path ./logs --service api,db --level error + +# Analyze last hour only +debugai analyze path ./logs --since 1h + +# Save as JSON +debugai analyze path ./logs --format json --save report.json +``` + +### analyze docker + +Analyze Docker container logs. + +```bash +debugai analyze docker [OPTIONS] +``` + +**Arguments:** +- `containers`: One or more container names/IDs + +**Options:** +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--tail` | | Number of recent log lines | 1000 | +| `--follow` | `-f` | Stream logs in real-time | false | +| All options from `analyze path` | | | | + +**Examples:** + +```bash +# Analyze single container +debugai analyze docker my-api + +# Analyze multiple containers +debugai analyze docker api db redis + +# Real-time streaming +debugai analyze docker api --follow + +# Last 500 lines +debugai analyze docker api --tail 500 +``` + +### analyze stream + +Analyze log streams in real-time. + +```bash +debugai analyze stream [OPTIONS] +``` + +**Arguments:** +- `source`: File path or `stdin` + +**Options:** +- All options from `analyze path` + +**Examples:** + +```bash +# Stream from file +debugai analyze stream /var/log/app.log + +# From stdin +tail -f app.log | debugai analyze stream stdin + +# With filters +debugai analyze stream app.log --level error --ai +``` + +## debugai explain + +Get plain English explanations for errors. + +```bash +debugai explain {error|text} [OPTIONS] +``` + +**Subcommands:** + +### explain error + +Explain by error ID from previous analysis. + +```bash +debugai explain error +``` + +### explain text + +Explain any error text. + +```bash +debugai explain text "" +``` + +**Options:** +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--verbose` | `-v` | Include technical details | false | +| `--context` | `-c` | Include surrounding context | true | + +**Examples:** + +```bash +# Explain by ID +debugai explain error err_abc123 + +# Explain text +debugai explain text "NullPointerException at line 42" + +# Verbose explanation +debugai explain text "Connection timeout" --verbose +``` + +## debugai suggest-fix + +Get AI-powered fix suggestions. + +```bash +debugai suggest-fix {error|text} [OPTIONS] +``` + +**Options:** +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--max` | `-m` | Maximum suggestions | 3 | +| `--lang` | `-l` | Programming language hint | auto | +| `--confidence` | | Minimum confidence threshold | 0.7 | + +**Examples:** + +```bash +# Get suggestions +debugai suggest-fix text "ModuleNotFoundError: No module named 'requests'" + +# Limit suggestions +debugai suggest-fix error err_123 --max 5 + +# Specify language +debugai suggest-fix text "undefined variable" --lang python +``` + +## debugai timeline + +View event timelines. + +```bash +debugai timeline show [OPTIONS] +``` + +**Options:** +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--last` | | Time window (e.g., "1h", "30m") | 1h | +| `--error` | `-e` | Focus on specific error | - | +| `--service` | `-s` | Filter by services | all | +| `--filter` | `-f` | Filter type (all, errors, warnings) | all | + +**Examples:** + +```bash +# Show last hour +debugai timeline show --last 1h + +# Focus on error +debugai timeline show --error err_123 + +# Filter errors only +debugai timeline show --filter errors +``` + +## debugai config + +Manage configuration. + +### config show + +Display current configuration. + +```bash +debugai config show +``` + +### config set + +Set configuration value. + +```bash +debugai config set +``` + +**Examples:** + +```bash +debugai config set api-key YOUR_KEY +debugai config set theme matrix +debugai config set ai-model gemini-1.5-flash +``` + +### config list-themes + +List available themes. + +```bash +debugai config list-themes +``` + +### config reset + +Reset to default configuration. + +```bash +debugai config reset +``` + +## debugai init + +Initialize DebugAI in current directory. + +```bash +debugai init [OPTIONS] +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--force` | Overwrite existing configuration | + +## debugai interactive + +Start interactive debugging session. + +```bash +debugai interactive [OPTIONS] +``` + +Interactive commands: +- `/analyze ` - Analyze logs +- `/explain ` - Explain error +- `/suggest ` - Get suggestions +- `/timeline` - Show timeline +- `/help` - Show help +- `/quit` - Exit + +## debugai doctor + +Check DebugAI health and configuration. + +```bash +debugai doctor +``` + +Checks: +- āœ… Python version +- āœ… Dependencies installed +- āœ… API key configured +- āœ… Database accessible +- āœ… Network connectivity + +## debugai logs + +Manage DebugAI's internal logs. + +```bash +debugai logs [OPTIONS] +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--tail` | Show last N lines | +| `--follow` | Stream logs | +| `--clear` | Clear log history | + +## Global Options + +Available for all commands: + +| Option | Short | Description | +|--------|-------|-------------| +| `--help` | `-h` | Show help message | +| `--version` | `-V` | Show version | +| `--verbose` | `-v` | Verbose output | +| `--quiet` | `-q` | Minimal output | +| `--no-color` | | Disable colors | + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Configuration error | +| 3 | API error | +| 4 | File not found | +| 5 | Permission denied | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3f00f4b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,115 @@ +# DebugAI Documentation + +Welcome to **DebugAI** - the AI-powered log analysis and debugging CLI that reduces debugging time by 60-75%. + +
+ +- :material-rocket-launch:{ .lg .middle } **Quick Start** + + --- + + Get up and running with DebugAI in minutes. + + [:octicons-arrow-right-24: Installation](getting-started/installation.md) + +- :material-book-open-variant:{ .lg .middle } **User Guide** + + --- + + Learn how to use all of DebugAI's powerful features. + + [:octicons-arrow-right-24: Commands](guide/commands.md) + +- :material-code-braces:{ .lg .middle } **API Reference** + + --- + + Detailed API documentation for developers. + + [:octicons-arrow-right-24: API Docs](api/parser.md) + +- :material-github:{ .lg .middle } **Contributing** + + --- + + Help make DebugAI even better! + + [:octicons-arrow-right-24: Contribute](development/contributing.md) + +
+ +## ✨ Features + +### šŸ¤– AI-Powered Analysis +Uses Google Gemini to analyze errors and suggest intelligent fixes with confidence scores. + +### šŸ“– Plain English Explanations +Transform cryptic stack traces into understandable explanations. + +### šŸ”— Cross-Service Correlation +Automatically trace errors across distributed systems and microservices. + +### šŸ“… Timeline Generation +Visualize the sequence of events leading to crashes. + +### 🐳 Docker Integration +Analyze container logs directly without manual exports. + +### ⚔ Lightweight & Fast +No ELK stack required - works locally on your machine. + +## šŸŽÆ Why DebugAI? + +| Traditional Debugging | With DebugAI | +|:---------------------|:-------------| +| āŒ Manually grep through thousands of log lines | āœ… AI identifies root causes instantly | +| āŒ Struggle to understand cryptic stack traces | āœ… Plain English explanations | +| āŒ Miss correlations between services | āœ… Automatic cross-service correlation | +| āŒ Hours to find the root cause | āœ… Minutes with AI-powered analysis | + +## šŸš€ Quick Example + +```bash +# Analyze logs from a directory +debugai analyze path ./logs + +# Get AI explanation for an error +debugai explain text "NullPointerException in UserService.getUser()" + +# Analyze Docker container logs +debugai analyze docker my-api-container --tail 500 + +# Get fix suggestions +debugai suggest-fix text "Connection refused to database" +``` + +## šŸ“¦ Installation + +```bash +pip install debugai +``` + +Or install from source: + +```bash +git clone https://github.com/akshaydubey05/debug-ai.git +cd debug-ai/debugai +pip install -e ".[dev]" +``` + +## šŸŽ® Next Steps + +- [Installation Guide](getting-started/installation.md) +- [Quick Start Tutorial](getting-started/quickstart.md) +- [Command Reference](guide/commands.md) +- [API Documentation](api/parser.md) + +## šŸ¤ Community + +- **GitHub:** [akshaydubey05/debug-ai](https://github.com/akshaydubey05/debug-ai) +- **Issues:** [Report bugs or request features](https://github.com/akshaydubey05/debug-ai/issues) +- **Discussions:** [Join the conversation](https://github.com/akshaydubey05/debug-ai/discussions) + +## šŸ“„ License + +DebugAI is released under the [MIT License](https://github.com/akshaydubey05/debug-ai/blob/main/LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a8752c7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,186 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "debugai" +version = "1.0.0" +description = "AI-Powered Log Analysis & Debugging CLI - Reduce debugging time by 60-75%" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [ + { name = "DebugAI Team", email = "team@debugai.dev" } +] +keywords = [ + "debugging", + "logging", + "ai", + "cli", + "developer-tools", + "log-analysis", + "error-tracking", + "gemini", + "devops" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Debuggers", + "Topic :: System :: Logging", + "Topic :: Utilities", +] + +dependencies = [ + # CLI Framework + "typer[all]>=0.9.0", + "rich>=13.7.0", + "click>=8.1.7", + + # AI & ML + "google-generativeai>=0.3.0", + + # Log Parsing + "python-dateutil>=2.8.2", + "regex>=2023.12.25", + + # Data Processing + "pandas>=2.1.4", + "numpy>=1.26.3", + "cachetools>=4.2.1", + + # Storage + "sqlalchemy>=2.0.25", + "aiosqlite>=0.19.0", + + # Configuration + "pydantic>=2.5.3", + "pydantic-settings>=2.1.0", + "pyyaml>=6.0.1", + "toml>=0.10.2", + + # Docker Integration + "docker>=7.0.0", + + # HTTP & Async + "httpx>=0.26.0", + "aiofiles>=23.2.1", + + # Utilities + "watchdog>=3.0.0", + "python-dotenv>=1.0.0", + "tenacity>=8.2.3", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.4", + "pytest-asyncio>=0.23.3", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "mypy>=1.8.0", + "ruff>=0.1.11", + "black>=23.12.1", + "pre-commit>=3.6.0", + "ipython>=8.20.0", +] +docs = [ + "mkdocs>=1.5.3", + "mkdocs-material>=9.5.3", + "mkdocstrings[python]>=0.24.0", +] + +[project.scripts] +debugai = "debugai.cli.main:app" + +[project.urls] +Homepage = "https://github.com/akshaydubey05/debug-ai" +Documentation = "https://akshaydubey05.github.io/debug-ai/" +Repository = "https://github.com/akshaydubey05/debug-ai" +Issues = "https://github.com/akshaydubey05/debug-ai/issues" +Changelog = "https://github.com/akshaydubey05/debug-ai/blob/main/CHANGELOG.md" +"Source Code" = "https://github.com/akshaydubey05/debug-ai" + +[tool.hatch.build.targets.wheel] +packages = ["src/debugai"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/README.md", + "/LICENSE", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by black) + "B008", # do not perform function calls in argument defaults + "B905", # zip without explicit strict +] + +[tool.ruff.isort] +known-first-party = ["debugai"] + +[tool.black] +target-version = ["py311"] +line-length = 100 + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "silent" +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "-v", + "--tb=short", + "--cov=src/debugai", + "--cov-report=term-missing", + "--cov-report=html", +] + +[tool.coverage.run] +source = ["src/debugai"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/sample_logs/api-server.log b/sample_logs/api-server.log new file mode 100644 index 0000000..ce15f8c --- /dev/null +++ b/sample_logs/api-server.log @@ -0,0 +1,23 @@ +2024-12-03 10:00:01 INFO [api] Server starting on port 8080 +2024-12-03 10:00:02 INFO [api] Connected to database successfully +2024-12-03 10:00:03 INFO [api] Loading configuration from config.yaml +2024-12-03 10:00:05 INFO [api] API server ready to accept connections +2024-12-03 10:01:15 INFO [api] GET /api/users - 200 OK - 45ms +2024-12-03 10:01:18 INFO [api] GET /api/products - 200 OK - 32ms +2024-12-03 10:02:30 WARN [api] Slow query detected: SELECT * FROM orders - 2340ms +2024-12-03 10:02:45 INFO [api] POST /api/orders - 201 Created - 156ms +2024-12-03 10:03:00 ERROR [api] Database connection timeout after 30000ms +2024-12-03 10:03:01 ERROR [api] Failed to execute query: Connection pool exhausted +2024-12-03 10:03:02 WARN [api] Retrying database connection (attempt 1/3) +2024-12-03 10:03:05 WARN [api] Retrying database connection (attempt 2/3) +2024-12-03 10:03:08 ERROR [api] Database connection failed after 3 retries +2024-12-03 10:03:08 ERROR [api] NullPointerException in UserService.getUserById(UserService.java:142) + at com.app.service.UserService.getUserById(UserService.java:142) + at com.app.controller.UserController.getUser(UserController.java:58) + at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:897) +2024-12-03 10:03:09 ERROR [api] Request failed: GET /api/users/123 - 500 Internal Server Error +2024-12-03 10:03:10 WARN [api] Circuit breaker opened for database service +2024-12-03 10:03:15 INFO [api] Fallback: Returning cached user data +2024-12-03 10:04:00 INFO [api] Database connection restored +2024-12-03 10:04:01 INFO [api] Circuit breaker closed diff --git a/sample_logs/database.log b/sample_logs/database.log new file mode 100644 index 0000000..28878c2 --- /dev/null +++ b/sample_logs/database.log @@ -0,0 +1,15 @@ +2024-12-03 10:00:00 INFO [db] PostgreSQL 15.2 starting up +2024-12-03 10:00:01 INFO [db] Listening on port 5432 +2024-12-03 10:00:02 INFO [db] Database system is ready to accept connections +2024-12-03 10:01:00 INFO [db] Connection accepted from 192.168.1.100 +2024-12-03 10:02:28 WARN [db] Query execution time exceeded threshold: 2340ms +2024-12-03 10:02:29 WARN [db] High memory usage detected: 85% of available memory +2024-12-03 10:02:55 ERROR [db] Too many connections: max_connections=100 reached +2024-12-03 10:02:56 ERROR [db] Connection rejected from 192.168.1.100: connection limit exceeded +2024-12-03 10:02:57 ERROR [db] Connection rejected from 192.168.1.101: connection limit exceeded +2024-12-03 10:02:58 WARN [db] Connection pool at 100% capacity +2024-12-03 10:03:00 ERROR [db] FATAL: remaining connection slots reserved for superuser +2024-12-03 10:03:01 ERROR [db] Terminating connection due to timeout +2024-12-03 10:03:30 INFO [db] Connection terminated: 192.168.1.100 +2024-12-03 10:03:45 INFO [db] Connection pool usage dropped to 75% +2024-12-03 10:04:00 INFO [db] New connection accepted from 192.168.1.100 diff --git a/sample_logs/redis.log b/sample_logs/redis.log new file mode 100644 index 0000000..fb37736 --- /dev/null +++ b/sample_logs/redis.log @@ -0,0 +1,9 @@ +{"timestamp":"2024-12-03T10:00:00Z","level":"info","service":"redis","message":"Redis 7.0.5 starting"} +{"timestamp":"2024-12-03T10:00:01Z","level":"info","service":"redis","message":"Server initialized","port":6379} +{"timestamp":"2024-12-03T10:01:00Z","level":"info","service":"redis","message":"Accepted connection from 192.168.1.100:45678"} +{"timestamp":"2024-12-03T10:02:00Z","level":"warn","service":"redis","message":"Memory usage high","used_memory":"1.2GB","max_memory":"1.5GB"} +{"timestamp":"2024-12-03T10:02:30Z","level":"error","service":"redis","message":"Out of memory","command":"SET","key":"session:user:12345"} +{"timestamp":"2024-12-03T10:02:31Z","level":"error","service":"redis","message":"OOM command not allowed when used memory > maxmemory"} +{"timestamp":"2024-12-03T10:02:32Z","level":"warn","service":"redis","message":"Evicting keys using LRU policy"} +{"timestamp":"2024-12-03T10:02:35Z","level":"info","service":"redis","message":"Evicted 150 keys","freed_memory":"200MB"} +{"timestamp":"2024-12-03T10:03:00Z","level":"info","service":"redis","message":"Memory usage normalized","used_memory":"1.0GB"} diff --git a/src/debugai/__init__.py b/src/debugai/__init__.py new file mode 100644 index 0000000..4f141c3 --- /dev/null +++ b/src/debugai/__init__.py @@ -0,0 +1,63 @@ +""" +DebugAI - AI-Powered Log Analysis & Debugging CLI + +Reduce debugging time by 60-75% with AI-powered log analysis, +error correlation, and intelligent fix suggestions. +""" + +import os +from pathlib import Path + +__version__ = "1.0.0" +__author__ = "DebugAI Team" +__license__ = "MIT" + + +def _load_env_file(): + """Load environment variables from .env file if it exists.""" + # Check multiple locations for .env file + locations = [ + Path.cwd() / ".env", + Path.cwd() / ".env.example", + Path(__file__).parent.parent.parent.parent / ".env", + Path(__file__).parent.parent.parent.parent / ".env.example", + ] + + for env_path in locations: + if env_path.exists(): + try: + with open(env_path, "r") as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + # Parse KEY=VALUE + if "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + # Remove quotes if present + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + # Only set if not already set + if key and not os.environ.get(key): + os.environ[key] = value + break # Stop after first .env found + except Exception: + pass # Silently ignore errors + + +# Auto-load .env on import +_load_env_file() + +from debugai.core.analyzer import LogAnalyzer +from debugai.core.engine import DebugEngine + +__all__ = [ + "__version__", + "LogAnalyzer", + "DebugEngine", +] diff --git a/src/debugai/ai/__init__.py b/src/debugai/ai/__init__.py new file mode 100644 index 0000000..2575216 --- /dev/null +++ b/src/debugai/ai/__init__.py @@ -0,0 +1,5 @@ +"""AI Package - Gemini AI integration for analysis""" + +from debugai.ai.gemini_client import GeminiClient + +__all__ = ["GeminiClient"] diff --git a/src/debugai/ai/embeddings.py b/src/debugai/ai/embeddings.py new file mode 100644 index 0000000..1a7a7b8 --- /dev/null +++ b/src/debugai/ai/embeddings.py @@ -0,0 +1,157 @@ +""" +Embeddings - Text embeddings for semantic search + +Uses sentence-transformers for local embeddings or Gemini for cloud embeddings. +""" + +from typing import List, Optional +import numpy as np + + +class EmbeddingModel: + """ + Manages text embeddings for semantic similarity search. + """ + + def __init__(self, model_name: str = "all-MiniLM-L6-v2", use_local: bool = True): + """ + Initialize embedding model. + + Args: + model_name: Model to use for embeddings + use_local: Use local model (sentence-transformers) vs cloud + """ + self.model_name = model_name + self.use_local = use_local + self._model = None + self._dimension = 384 # Default for MiniLM + + def _load_model(self): + """Load the embedding model.""" + if self._model is None: + if self.use_local: + try: + from sentence_transformers import SentenceTransformer + self._model = SentenceTransformer(self.model_name) + self._dimension = self._model.get_sentence_embedding_dimension() + except ImportError: + raise ImportError( + "sentence-transformers not installed. " + "Run: pip install sentence-transformers" + ) + else: + # Use Gemini embeddings + self._model = "gemini" + return self._model + + def embed(self, text: str) -> List[float]: + """ + Generate embedding for a single text. + + Args: + text: Text to embed + + Returns: + Embedding vector as list of floats + """ + model = self._load_model() + + if self.use_local: + embedding = model.encode(text, convert_to_numpy=True) + return embedding.tolist() + else: + return self._embed_with_gemini(text) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for multiple texts. + + Args: + texts: List of texts to embed + + Returns: + List of embedding vectors + """ + model = self._load_model() + + if self.use_local: + embeddings = model.encode(texts, convert_to_numpy=True) + return embeddings.tolist() + else: + return [self._embed_with_gemini(text) for text in texts] + + def _embed_with_gemini(self, text: str) -> List[float]: + """Generate embedding using Gemini.""" + import google.generativeai as genai + import os + + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + raise ValueError("GEMINI_API_KEY not set") + + genai.configure(api_key=api_key) + + result = genai.embed_content( + model="models/embedding-001", + content=text, + task_type="retrieval_document" + ) + + return result["embedding"] + + def similarity(self, embedding1: List[float], embedding2: List[float]) -> float: + """ + Calculate cosine similarity between two embeddings. + + Args: + embedding1: First embedding + embedding2: Second embedding + + Returns: + Similarity score (0-1) + """ + vec1 = np.array(embedding1) + vec2 = np.array(embedding2) + + dot_product = np.dot(vec1, vec2) + norm1 = np.linalg.norm(vec1) + norm2 = np.linalg.norm(vec2) + + if norm1 == 0 or norm2 == 0: + return 0.0 + + return float(dot_product / (norm1 * norm2)) + + def find_similar( + self, + query_embedding: List[float], + embeddings: List[List[float]], + top_k: int = 5 + ) -> List[tuple]: + """ + Find most similar embeddings to query. + + Args: + query_embedding: Query embedding + embeddings: List of embeddings to search + top_k: Number of results to return + + Returns: + List of (index, similarity_score) tuples + """ + similarities = [] + + for i, emb in enumerate(embeddings): + sim = self.similarity(query_embedding, emb) + similarities.append((i, sim)) + + # Sort by similarity (descending) + similarities.sort(key=lambda x: x[1], reverse=True) + + return similarities[:top_k] + + @property + def dimension(self) -> int: + """Get embedding dimension.""" + self._load_model() + return self._dimension diff --git a/src/debugai/ai/gemini_client.py b/src/debugai/ai/gemini_client.py new file mode 100644 index 0000000..cc00973 --- /dev/null +++ b/src/debugai/ai/gemini_client.py @@ -0,0 +1,390 @@ +""" +Gemini Client - Google Gemini AI integration + +This module handles all AI-powered analysis using Google's Gemini API. +""" + +from typing import Dict, Any, List, Optional +import os +import json + + +class GeminiClient: + """ + Client for Google Gemini AI API. + Handles error analysis, explanations, and fix suggestions. + """ + + def __init__(self, api_key: Optional[str] = None, model: str = "gemini-flash-latest"): + """ + Initialize Gemini client. + + Args: + api_key: Gemini API key (or set GEMINI_API_KEY env var) + model: Model to use (default: gemini-flash-latest) + """ + self.api_key = api_key or os.environ.get("GEMINI_API_KEY") + self.model_name = model + self._model = None + self._initialized = False + + def _get_model(self): + """Initialize and return the Gemini model.""" + if self._model is None: + if not self.api_key: + raise ValueError( + "Gemini API key not found. Set GEMINI_API_KEY environment variable " + "or run 'debugai config set api-key YOUR_KEY'" + ) + + try: + import google.generativeai as genai + + genai.configure(api_key=self.api_key) + self._model = genai.GenerativeModel(self.model_name) + self._initialized = True + except ImportError: + raise ImportError( + "google-generativeai package not installed. " + "Run: pip install google-generativeai" + ) + + return self._model + + def analyze_errors( + self, + errors: List[Dict[str, Any]], + context: str, + max_errors: int = 10 + ) -> Dict[str, Any]: + """ + Analyze errors and provide insights. + + Args: + errors: List of error entries + context: Additional context from logs + max_errors: Maximum errors to analyze + + Returns: + Analysis results with root causes and suggestions + """ + from debugai.ai.prompts import ERROR_ANALYSIS_PROMPT + + model = self._get_model() + + # Prepare error summary + error_text = self._format_errors(errors[:max_errors]) + + prompt = ERROR_ANALYSIS_PROMPT.format( + errors=error_text, + context=context[:4000], # Limit context size + error_count=len(errors) + ) + + try: + response = model.generate_content(prompt) + return self._parse_analysis_response(response.text) + except Exception as e: + return { + "error": str(e), + "root_causes": [], + "suggestions": [], + "summary": f"Analysis failed: {e}" + } + + def explain_error( + self, + error: Dict[str, Any], + verbose: bool = False + ) -> Dict[str, Any]: + """ + Explain an error in plain English. + + Args: + error: Error entry to explain + verbose: Include technical details + + Returns: + Explanation dictionary + """ + from debugai.ai.prompts import ERROR_EXPLANATION_PROMPT + + model = self._get_model() + + prompt = ERROR_EXPLANATION_PROMPT.format( + error_message=error.get("message", error.get("raw", "")), + error_level=error.get("level", "error"), + service=error.get("service", "unknown"), + timestamp=error.get("timestamp", "unknown"), + verbose="Include detailed technical analysis." if verbose else "" + ) + + try: + response = model.generate_content(prompt) + return self._parse_explanation_response(response.text, verbose) + except Exception as e: + return { + "summary": f"Could not explain error: {e}", + "technical_details": None, + "similar_issues": [] + } + + def explain_text(self, error_text: str) -> str: + """ + Explain any error text directly. + + Args: + error_text: Error message or stack trace + + Returns: + Plain English explanation + """ + from debugai.ai.prompts import TEXT_EXPLANATION_PROMPT + + model = self._get_model() + + prompt = TEXT_EXPLANATION_PROMPT.format(error_text=error_text) + + try: + response = model.generate_content(prompt) + return response.text + except Exception as e: + return f"Could not explain: {e}" + + def suggest_fixes( + self, + error: Dict[str, Any], + max_suggestions: int = 3 + ) -> List[Dict[str, Any]]: + """ + Suggest fixes for an error. + + Args: + error: Error entry + max_suggestions: Maximum number of suggestions + + Returns: + List of fix suggestions + """ + from debugai.ai.prompts import FIX_SUGGESTION_PROMPT + + model = self._get_model() + + prompt = FIX_SUGGESTION_PROMPT.format( + error_message=error.get("message", error.get("raw", "")), + service=error.get("service", "unknown"), + max_suggestions=max_suggestions + ) + + try: + response = model.generate_content(prompt) + return self._parse_suggestions_response(response.text, max_suggestions) + except Exception as e: + return [{ + "title": "Analysis Failed", + "description": str(e), + "confidence": 0, + "code": None + }] + + def suggest_for_text( + self, + error_text: str, + language: str = "python", + max_suggestions: int = 3 + ) -> List[Dict[str, Any]]: + """ + Suggest fixes for any error text. + + Args: + error_text: Error message + language: Programming language + max_suggestions: Maximum suggestions + + Returns: + List of suggestions + """ + from debugai.ai.prompts import TEXT_FIX_PROMPT + + model = self._get_model() + + prompt = TEXT_FIX_PROMPT.format( + error_text=error_text, + language=language, + max_suggestions=max_suggestions + ) + + try: + response = model.generate_content(prompt) + return self._parse_suggestions_response(response.text, max_suggestions) + except Exception as e: + return [{ + "title": "Analysis Failed", + "description": str(e), + "confidence": 0 + }] + + def correlate_errors( + self, + errors: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Find correlations between errors. + + Args: + errors: List of errors to correlate + + Returns: + Correlation analysis + """ + from debugai.ai.prompts import CORRELATION_PROMPT + + model = self._get_model() + + error_text = self._format_errors(errors[:20]) + prompt = CORRELATION_PROMPT.format(errors=error_text) + + try: + response = model.generate_content(prompt) + return self._parse_correlation_response(response.text) + except Exception as e: + return {"error": str(e), "correlations": []} + + def _format_errors(self, errors: List[Dict[str, Any]]) -> str: + """Format errors for prompt.""" + lines = [] + for i, error in enumerate(errors, 1): + lines.append(f"{i}. [{error.get('level', 'ERROR')}] {error.get('service', 'unknown')}") + lines.append(f" Time: {error.get('timestamp', 'unknown')}") + lines.append(f" Message: {error.get('message', error.get('raw', ''))[:500]}") + lines.append("") + return "\n".join(lines) + + def _parse_analysis_response(self, text: str) -> Dict[str, Any]: + """Parse AI analysis response.""" + result = { + "root_causes": [], + "suggestions": [], + "summary": "" + } + + # Try to parse as JSON first + try: + if "```json" in text: + json_str = text.split("```json")[1].split("```")[0] + return json.loads(json_str) + elif text.strip().startswith("{"): + return json.loads(text) + except: + pass + + # Parse text response + lines = text.split("\n") + current_section = None + + for line in lines: + line = line.strip() + if "root cause" in line.lower(): + current_section = "root_causes" + elif "suggestion" in line.lower() or "fix" in line.lower(): + current_section = "suggestions" + elif "summary" in line.lower(): + current_section = "summary" + elif line and current_section: + if current_section == "summary": + result["summary"] += line + " " + elif current_section == "root_causes": + if line.startswith(("-", "*", "•", "1", "2", "3")): + result["root_causes"].append({ + "title": line.lstrip("-*•0123456789. "), + "explanation": "", + "confidence": 70 + }) + elif current_section == "suggestions": + if line.startswith(("-", "*", "•", "1", "2", "3")): + result["suggestions"].append({ + "title": line.lstrip("-*•0123456789. "), + "description": "", + "code": None + }) + + if not result["summary"]: + result["summary"] = text[:500] + + return result + + def _parse_explanation_response(self, text: str, verbose: bool) -> Dict[str, Any]: + """Parse explanation response.""" + result = { + "summary": text, + "technical_details": None, + "similar_issues": [] + } + + if verbose and "\n\n" in text: + parts = text.split("\n\n", 1) + result["summary"] = parts[0] + result["technical_details"] = parts[1] if len(parts) > 1 else None + + return result + + def _parse_suggestions_response( + self, + text: str, + max_suggestions: int + ) -> List[Dict[str, Any]]: + """Parse suggestions response.""" + suggestions = [] + + # Try JSON first + try: + if "```json" in text: + json_str = text.split("```json")[1].split("```")[0] + data = json.loads(json_str) + if isinstance(data, list): + return data[:max_suggestions] + except: + pass + + # Parse text + current_suggestion = None + code_block = None + in_code = False + + for line in text.split("\n"): + if line.startswith("```") and not in_code: + in_code = True + code_block = [] + continue + elif line.startswith("```") and in_code: + in_code = False + if current_suggestion and code_block: + current_suggestion["code"] = "\n".join(code_block) + code_block = None + continue + + if in_code and code_block is not None: + code_block.append(line) + elif line.strip().startswith(("1.", "2.", "3.", "-", "*", "•")): + if current_suggestion: + suggestions.append(current_suggestion) + current_suggestion = { + "title": line.lstrip("-*•0123456789. ").strip(), + "description": "", + "confidence": 70, + "code": None + } + elif current_suggestion and line.strip(): + current_suggestion["description"] += line.strip() + " " + + if current_suggestion: + suggestions.append(current_suggestion) + + return suggestions[:max_suggestions] + + def _parse_correlation_response(self, text: str) -> Dict[str, Any]: + """Parse correlation response.""" + return { + "correlations": [], + "summary": text + } diff --git a/src/debugai/ai/prompts.py b/src/debugai/ai/prompts.py new file mode 100644 index 0000000..2890dcf --- /dev/null +++ b/src/debugai/ai/prompts.py @@ -0,0 +1,188 @@ +""" +AI Prompts - Prompt templates for Gemini AI + +These prompts are carefully engineered for optimal debugging assistance. +""" + +# Error Analysis Prompt +ERROR_ANALYSIS_PROMPT = """You are an expert debugging assistant analyzing application logs. + +## Errors Found ({error_count} total): +{errors} + +## Additional Context: +{context} + +## Your Task: +Analyze these errors and provide: + +1. **Root Cause Analysis**: Identify the primary root cause(s) of these errors +2. **Error Correlation**: Explain how these errors might be related +3. **Impact Assessment**: What is the likely impact on the system? +4. **Suggested Fixes**: Provide actionable fix suggestions with code examples +5. **Prevention**: How can these errors be prevented in the future? + +Respond in this JSON format: +```json +{{ + "root_causes": [ + {{ + "title": "Brief title", + "explanation": "Detailed explanation", + "confidence": 85, + "affected_services": ["service1", "service2"] + }} + ], + "suggestions": [ + {{ + "title": "Fix title", + "description": "What to do", + "code": "example code if applicable", + "language": "python", + "priority": "high" + }} + ], + "summary": "Plain English summary of what happened and why" +}} +``` +""" + +# Error Explanation Prompt +ERROR_EXPLANATION_PROMPT = """You are a helpful debugging assistant. Explain this error in plain English that any developer can understand. + +## Error Details: +- **Level**: {error_level} +- **Service**: {service} +- **Time**: {timestamp} +- **Message**: {error_message} + +{verbose} + +## Instructions: +1. Explain what this error means in simple terms +2. Explain why this error typically occurs +3. Describe the potential impact +4. If there's a stack trace, explain the flow + +Keep your explanation clear and actionable. Avoid jargon unless necessary, and explain any technical terms you use. +""" + +# Text Explanation Prompt +TEXT_EXPLANATION_PROMPT = """You are a helpful debugging assistant. Explain this error or log message in plain English: + +``` +{error_text} +``` + +Provide: +1. **What it means**: A clear, simple explanation +2. **Why it happens**: Common causes +3. **What to do**: Quick steps to investigate or fix + +Keep it concise and practical. +""" + +# Fix Suggestion Prompt +FIX_SUGGESTION_PROMPT = """You are an expert developer helping to fix an error. + +## Error: +- **Service**: {service} +- **Message**: {error_message} + +## Your Task: +Provide {max_suggestions} actionable fix suggestions. + +For each suggestion, provide: +1. A clear title +2. Step-by-step description +3. Code example if applicable +4. Confidence level (0-100) + +Respond in JSON format: +```json +[ + {{ + "title": "Fix Title", + "description": "Detailed steps to fix", + "code": "// code example", + "language": "python", + "confidence": 85 + }} +] +``` +""" + +# Text Fix Prompt +TEXT_FIX_PROMPT = """You are an expert {language} developer helping to fix an error. + +## Error: +``` +{error_text} +``` + +Provide {max_suggestions} specific, actionable fixes. + +For each fix: +1. Clear title +2. What to change and why +3. Code example +4. Confidence percentage + +Respond in JSON format: +```json +[ + {{ + "title": "Fix Title", + "description": "What to do", + "code": "corrected code", + "language": "{language}", + "confidence": 80 + }} +] +``` +""" + +# Error Correlation Prompt +CORRELATION_PROMPT = """You are analyzing multiple errors to find correlations and the root cause. + +## Errors: +{errors} + +## Analyze: +1. Are these errors related? How? +2. What's the sequence of events that led to these errors? +3. Which error is the root cause, and which are symptoms? +4. What service or component is the common factor? + +Provide a clear analysis of how these errors are connected and what the underlying issue is. +""" + +# Timeline Analysis Prompt +TIMELINE_PROMPT = """Analyze this sequence of events and explain what happened: + +## Event Timeline: +{events} + +## Questions to Answer: +1. What triggered the initial problem? +2. How did the error propagate through the system? +3. What was the cascade effect? +4. At what point could intervention have prevented the failure? + +Provide a narrative explanation of the incident timeline. +""" + +# Pattern Detection Prompt +PATTERN_PROMPT = """Analyze these log patterns and identify: + +## Patterns Found: +{patterns} + +## Identify: +1. Which patterns indicate problems? +2. Are there any anomalies? +3. What do these patterns suggest about system health? +4. Any recommendations for alerting or monitoring? + +Provide actionable insights based on these patterns. +""" diff --git a/src/debugai/analysis/__init__.py b/src/debugai/analysis/__init__.py new file mode 100644 index 0000000..f95640c --- /dev/null +++ b/src/debugai/analysis/__init__.py @@ -0,0 +1,6 @@ +"""Analysis Package - Error correlation, pattern detection, timeline""" + +from debugai.analysis.correlator import ErrorCorrelator +from debugai.analysis.timeline_builder import TimelineBuilder + +__all__ = ["ErrorCorrelator", "TimelineBuilder"] diff --git a/src/debugai/analysis/correlator.py b/src/debugai/analysis/correlator.py new file mode 100644 index 0000000..b34487e --- /dev/null +++ b/src/debugai/analysis/correlator.py @@ -0,0 +1,276 @@ +""" +Error Correlator - Find relationships between errors across services +""" + +from typing import List, Dict, Any, Optional +from collections import defaultdict +from datetime import datetime, timedelta +import re + + +class ErrorCorrelator: + """ + Correlates errors across services to find root causes. + Uses temporal proximity, trace IDs, and semantic similarity. + """ + + def __init__(self, time_window: int = 60): + """ + Initialize correlator. + + Args: + time_window: Time window in seconds for correlation + """ + self.time_window = time_window + + def correlate(self, errors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Correlate errors and add correlation metadata. + + Args: + errors: List of error entries + + Returns: + Errors with correlation information added + """ + if not errors: + return errors + + # Group by trace ID if available + trace_groups = self._group_by_trace_id(errors) + + # Group by time proximity + time_groups = self._group_by_time(errors) + + # Find causal chains + chains = self._find_causal_chains(errors) + + # Add correlation metadata to errors + correlated = [] + for error in errors: + error_copy = error.copy() + error_copy["correlations"] = { + "trace_group": trace_groups.get(error.get("error_id")), + "time_group": time_groups.get(error.get("error_id")), + "chain_position": chains.get(error.get("error_id")), + } + correlated.append(error_copy) + + # Sort by likely root cause first + correlated.sort(key=lambda e: ( + e["correlations"].get("chain_position", 999), + e.get("timestamp", "") + )) + + return correlated + + def find_root_cause(self, errors: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + Identify the most likely root cause error. + + Args: + errors: List of error entries + + Returns: + Most likely root cause error + """ + if not errors: + return None + + # Score each error + scores = [] + for error in errors: + score = self._calculate_root_cause_score(error, errors) + scores.append((error, score)) + + # Return highest scoring error + scores.sort(key=lambda x: x[1], reverse=True) + return scores[0][0] if scores else None + + def group_related(self, errors: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: + """ + Group related errors together. + + Args: + errors: List of error entries + + Returns: + List of error groups + """ + if not errors: + return [] + + # Use union-find for grouping + parent = {i: i for i in range(len(errors))} + + def find(x): + if parent[x] != x: + parent[x] = find(parent[x]) + return parent[x] + + def union(x, y): + px, py = find(x), find(y) + if px != py: + parent[px] = py + + # Connect related errors + for i, error1 in enumerate(errors): + for j, error2 in enumerate(errors[i+1:], i+1): + if self._are_related(error1, error2): + union(i, j) + + # Group by parent + groups = defaultdict(list) + for i, error in enumerate(errors): + groups[find(i)].append(error) + + return list(groups.values()) + + def _group_by_trace_id(self, errors: List[Dict[str, Any]]) -> Dict[str, str]: + """Group errors by trace ID.""" + trace_groups = {} + trace_to_group = {} + group_counter = 0 + + for error in errors: + trace_id = error.get("metadata", {}).get("trace_id") + if trace_id: + if trace_id not in trace_to_group: + trace_to_group[trace_id] = f"trace_{group_counter}" + group_counter += 1 + trace_groups[error.get("error_id")] = trace_to_group[trace_id] + + return trace_groups + + def _group_by_time(self, errors: List[Dict[str, Any]]) -> Dict[str, str]: + """Group errors by temporal proximity.""" + time_groups = {} + group_counter = 0 + + # Sort by timestamp + sorted_errors = sorted(errors, key=lambda e: e.get("timestamp", "")) + + current_group = None + last_time = None + + for error in sorted_errors: + timestamp_str = error.get("timestamp") + if not timestamp_str: + continue + + try: + # Parse timestamp (simplified) + current_time = self._parse_timestamp(timestamp_str) + + if last_time is None or (current_time - last_time).total_seconds() > self.time_window: + current_group = f"time_{group_counter}" + group_counter += 1 + + time_groups[error.get("error_id")] = current_group + last_time = current_time + except: + pass + + return time_groups + + def _find_causal_chains(self, errors: List[Dict[str, Any]]) -> Dict[str, int]: + """Find causal chains (which error caused which).""" + chains = {} + + # Sort by timestamp + sorted_errors = sorted(errors, key=lambda e: e.get("timestamp", "")) + + for i, error in enumerate(sorted_errors): + chains[error.get("error_id")] = i + + return chains + + def _are_related(self, error1: Dict[str, Any], error2: Dict[str, Any]) -> bool: + """Check if two errors are related.""" + # Same trace ID + trace1 = error1.get("metadata", {}).get("trace_id") + trace2 = error2.get("metadata", {}).get("trace_id") + if trace1 and trace2 and trace1 == trace2: + return True + + # Same request ID + req1 = error1.get("metadata", {}).get("request_id") + req2 = error2.get("metadata", {}).get("request_id") + if req1 and req2 and req1 == req2: + return True + + # Close in time + try: + time1 = self._parse_timestamp(error1.get("timestamp", "")) + time2 = self._parse_timestamp(error2.get("timestamp", "")) + if abs((time1 - time2).total_seconds()) < self.time_window: + # Check if messages are similar + msg1 = error1.get("message", "").lower() + msg2 = error2.get("message", "").lower() + + # Simple similarity: shared words + words1 = set(msg1.split()) + words2 = set(msg2.split()) + overlap = len(words1 & words2) / max(len(words1 | words2), 1) + + if overlap > 0.3: + return True + except: + pass + + return False + + def _calculate_root_cause_score( + self, + error: Dict[str, Any], + all_errors: List[Dict[str, Any]] + ) -> float: + """Calculate how likely this error is the root cause.""" + score = 0.0 + + # Earlier errors are more likely root causes + try: + timestamps = [self._parse_timestamp(e.get("timestamp", "")) for e in all_errors if e.get("timestamp")] + error_time = self._parse_timestamp(error.get("timestamp", "")) + if timestamps: + min_time = min(timestamps) + max_time = max(timestamps) + time_range = (max_time - min_time).total_seconds() or 1 + position = (error_time - min_time).total_seconds() / time_range + score += (1 - position) * 50 # Earlier = higher score + except: + pass + + # Lower-level services are more likely root causes + service = error.get("service", "").lower() + if any(db in service for db in ["db", "database", "postgres", "mysql", "redis", "mongo"]): + score += 30 + elif any(infra in service for infra in ["queue", "kafka", "rabbit", "cache"]): + score += 20 + + # Certain error types suggest root cause + message = error.get("message", "").lower() + if any(term in message for term in ["connection refused", "timeout", "unavailable"]): + score += 25 + + return score + + def _parse_timestamp(self, timestamp_str: str) -> datetime: + """Parse timestamp string to datetime.""" + # Try common formats + formats = [ + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + ] + + # Clean the string + timestamp_str = timestamp_str[:19] # Take first 19 chars + + for fmt in formats: + try: + return datetime.strptime(timestamp_str, fmt) + except: + continue + + return datetime.now() diff --git a/src/debugai/analysis/timeline_builder.py b/src/debugai/analysis/timeline_builder.py new file mode 100644 index 0000000..0f70cf1 --- /dev/null +++ b/src/debugai/analysis/timeline_builder.py @@ -0,0 +1,239 @@ +""" +Timeline Builder - Generate event timelines for debugging +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta +from collections import defaultdict + + +class TimelineBuilder: + """ + Builds event timelines for debugging and incident analysis. + """ + + def __init__(self): + self._events: List[Dict[str, Any]] = [] + + def build( + self, + time_range: str = "5m", + filter_level: Optional[str] = None, + service: Optional[str] = None, + limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + Build a timeline of events. + + Args: + time_range: Time range (e.g., "5m", "1h", "1d") + filter_level: Filter by level ("errors", "warnings", "all") + service: Filter by service name + limit: Maximum events to return + + Returns: + List of timeline events + """ + from debugai.storage.database import Database + + db = Database() + + # Parse time range + since = self._parse_time_range(time_range) + + # Get events from database + events = db.get_events( + since=since, + level=filter_level, + service=service, + limit=limit + ) + + # Sort by timestamp + events.sort(key=lambda e: e.get("timestamp", "")) + + return events + + def trace_crash( + self, + error: Dict[str, Any], + before: str = "5m" + ) -> List[Dict[str, Any]]: + """ + Trace events leading up to a crash/error. + + Args: + error: The crash/error to trace back from + before: How far back to look + + Returns: + Timeline of events leading to the crash + """ + from debugai.storage.database import Database + + db = Database() + + # Get error timestamp + error_time = error.get("timestamp") + if not error_time: + return [] + + # Parse time range + time_delta = self._parse_time_delta(before) + + # Get events before the error + events = db.get_events_before( + timestamp=error_time, + delta=time_delta, + service=error.get("service") # Focus on same service + ) + + # Add related events from other services + trace_id = error.get("metadata", {}).get("trace_id") + if trace_id: + related = db.get_events_by_trace(trace_id) + events.extend(related) + + # Deduplicate and sort + seen = set() + unique_events = [] + for event in events: + event_id = event.get("error_id") or event.get("message", "")[:50] + if event_id not in seen: + seen.add(event_id) + unique_events.append(event) + + unique_events.sort(key=lambda e: e.get("timestamp", "")) + + return unique_events + + def build_incident_timeline( + self, + errors: List[Dict[str, Any]], + window: str = "10m" + ) -> Dict[str, Any]: + """ + Build a comprehensive incident timeline from multiple errors. + + Args: + errors: List of related errors + window: Time window around errors to include + + Returns: + Incident timeline with analysis + """ + if not errors: + return {"events": [], "analysis": {}} + + # Find time range + timestamps = [e.get("timestamp", "") for e in errors if e.get("timestamp")] + if not timestamps: + return {"events": errors, "analysis": {}} + + timestamps.sort() + start_time = timestamps[0] + end_time = timestamps[-1] + + # Build timeline + timeline = [] + + # Add errors + for error in errors: + timeline.append({ + "timestamp": error.get("timestamp"), + "type": "error", + "level": error.get("level", "error"), + "service": error.get("service", "unknown"), + "message": error.get("message", ""), + "is_error": True + }) + + # Sort timeline + timeline.sort(key=lambda e: e.get("timestamp", "")) + + # Analyze the timeline + analysis = self._analyze_timeline(timeline) + + return { + "start": start_time, + "end": end_time, + "duration": self._calculate_duration(start_time, end_time), + "events": timeline, + "analysis": analysis + } + + def _parse_time_range(self, time_range: str) -> datetime: + """Parse time range string and return start datetime.""" + delta = self._parse_time_delta(time_range) + return datetime.now() - delta + + def _parse_time_delta(self, time_str: str) -> timedelta: + """Parse time string to timedelta.""" + time_str = time_str.lower().strip() + + # Extract number and unit + import re + match = re.match(r"(\d+)\s*([mhds])", time_str) + if not match: + return timedelta(minutes=5) # Default + + value = int(match.group(1)) + unit = match.group(2) + + if unit == "m": + return timedelta(minutes=value) + elif unit == "h": + return timedelta(hours=value) + elif unit == "d": + return timedelta(days=value) + elif unit == "s": + return timedelta(seconds=value) + + return timedelta(minutes=5) + + def _calculate_duration(self, start: str, end: str) -> str: + """Calculate duration between two timestamps.""" + try: + start_dt = datetime.fromisoformat(start[:19]) + end_dt = datetime.fromisoformat(end[:19]) + delta = end_dt - start_dt + + seconds = int(delta.total_seconds()) + if seconds < 60: + return f"{seconds}s" + elif seconds < 3600: + return f"{seconds // 60}m {seconds % 60}s" + else: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return f"{hours}h {minutes}m" + except: + return "unknown" + + def _analyze_timeline(self, events: List[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze a timeline for patterns.""" + analysis = { + "total_events": len(events), + "error_count": 0, + "services_affected": set(), + "first_error": None, + "cascade_detected": False + } + + for event in events: + if event.get("level") in ("error", "critical"): + analysis["error_count"] += 1 + if analysis["first_error"] is None: + analysis["first_error"] = event + + service = event.get("service") + if service: + analysis["services_affected"].add(service) + + analysis["services_affected"] = list(analysis["services_affected"]) + + # Detect cascade (errors in multiple services) + if len(analysis["services_affected"]) > 1: + analysis["cascade_detected"] = True + + return analysis diff --git a/src/debugai/cli/__init__.py b/src/debugai/cli/__init__.py new file mode 100644 index 0000000..d4f2b74 --- /dev/null +++ b/src/debugai/cli/__init__.py @@ -0,0 +1 @@ +"""CLI Commands Package""" diff --git a/src/debugai/cli/commands/__init__.py b/src/debugai/cli/commands/__init__.py new file mode 100644 index 0000000..9cb2ca7 --- /dev/null +++ b/src/debugai/cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI Commands - Analyze Module""" diff --git a/src/debugai/cli/commands/analyze.py b/src/debugai/cli/commands/analyze.py new file mode 100644 index 0000000..afef9a8 --- /dev/null +++ b/src/debugai/cli/commands/analyze.py @@ -0,0 +1,539 @@ +""" +Analyze Command - Core log analysis functionality + +Commands: + debugai analyze ./logs --service api,db,redis + debugai analyze --docker container_name + debugai analyze --stdin +""" + +from pathlib import Path +from typing import Optional, List +from enum import Enum + +import typer +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn +from rich.panel import Panel +from rich.table import Table +from rich.tree import Tree +from rich.syntax import Syntax +from rich import box + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() + +app = typer.Typer( + name="analyze", + help="Analyze logs from multiple sources with AI-powered insights", + no_args_is_help=False, +) + + +@app.callback(invoke_without_command=True) +def analyze_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Analyze logs from multiple sources with AI-powered insights.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai analyze", + description="Analyze logs from multiple sources with AI-powered insights. Supports file paths, Docker containers, Kubernetes pods, and stdin.", + subcommands=[ + ("path", "Analyze logs from file or directory"), + ("docker", "Analyze Docker container logs"), + ("k8s", "Analyze Kubernetes pod logs"), + ("stdin", "Analyze logs from standard input"), + ], + examples=[ + "debugai analyze path ./logs", + "debugai analyze path ./logs --service api,db", + "debugai analyze docker my-container", + "cat app.log | debugai analyze stdin", + ] + ) + raise typer.Exit(0) + + +class OutputFormat(str, Enum): + """Output format options""" + RICH = "rich" + JSON = "json" + MARKDOWN = "markdown" + PLAIN = "plain" + + +class LogSource(str, Enum): + """Log source types""" + FILE = "file" + DOCKER = "docker" + KUBERNETES = "kubernetes" + STDIN = "stdin" + JOURNALD = "journald" + + +@app.command("path") +def analyze_path( + paths: List[Path] = typer.Argument( + ..., + help="Path(s) to log files or directories", + exists=True, + ), + service: Optional[str] = typer.Option( + None, + "--service", "-s", + help="Filter by service names (comma-separated: api,db,redis)", + ), + level: Optional[str] = typer.Option( + None, + "--level", "-l", + help="Filter by log level (error,warn,info,debug)", + ), + since: Optional[str] = typer.Option( + None, + "--since", + help="Analyze logs since time (e.g., '1h', '30m', '2024-01-01')", + ), + until: Optional[str] = typer.Option( + None, + "--until", + help="Analyze logs until time", + ), + pattern: Optional[str] = typer.Option( + None, + "--pattern", "-p", + help="Custom regex pattern to match", + ), + correlate: bool = typer.Option( + True, + "--correlate/--no-correlate", + help="Enable cross-service error correlation", + ), + ai_analysis: bool = typer.Option( + True, + "--ai/--no-ai", + help="Enable AI-powered analysis", + ), + output_format: OutputFormat = typer.Option( + OutputFormat.RICH, + "--format", "-f", + help="Output format", + ), + max_errors: int = typer.Option( + 50, + "--max-errors", + help="Maximum number of errors to analyze in detail", + ), + save_report: Optional[Path] = typer.Option( + None, + "--save", "-o", + help="Save analysis report to file", + ), +) -> None: + """ + Analyze log files or directories. + + Examples: + debugai analyze path ./logs + debugai analyze path ./logs --service api,db + debugai analyze path ./app.log ./error.log --level error + debugai analyze path ./logs --since 1h --correlate + """ + from debugai.core.engine import DebugEngine + from debugai.ingestion.file_ingester import FileIngester + from debugai.analysis.correlator import ErrorCorrelator + + # Get theme colors + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + + console.print(Panel.fit( + f"[bold {primary}]DebugAI Log Analysis[/bold {primary}]", + border_style=border + )) + + # Parse service filter + services = service.split(",") if service else None + levels = level.split(",") if level else None + + # Show configuration + config_table = Table(show_header=False, box=None, padding=(0, 2)) + config_table.add_column("Key", style=primary) + config_table.add_column("Value", style=text_color) + + config_table.add_row("Paths", ", ".join(str(p) for p in paths)) + if services: + config_table.add_row("Services", ", ".join(services)) + if levels: + config_table.add_row("Levels", ", ".join(levels)) + if since: + config_table.add_row("Since", since) + config_table.add_row("AI Analysis", "Enabled" if ai_analysis else "Disabled") + config_table.add_row("Correlation", "Enabled" if correlate else "Disabled") + + console.print(config_table) + console.print() + + # Run analysis with progress + with Progress( + SpinnerColumn(style=primary), + TextColumn(f"[{text_color}]" + "{task.description}" + f"[/{text_color}]"), + BarColumn(complete_style=primary, finished_style=primary), + TaskProgressColumn(), + console=console, + ) as progress: + # Step 1: Ingest logs + ingest_task = progress.add_task(f"[{primary}]Ingesting logs...", total=100) + + try: + engine = DebugEngine() + ingester = FileIngester() + + # Ingest all paths + all_logs = [] + for path in paths: + logs = ingester.ingest( + path=path, + services=services, + levels=levels, + since=since, + until=until, + ) + all_logs.extend(logs) + + progress.update(ingest_task, completed=100) + + # Step 2: Parse and structure + parse_task = progress.add_task(f"[{primary}]Parsing log entries...", total=100) + parsed_logs = engine.parse_logs(all_logs) + progress.update(parse_task, completed=100) + + # Step 3: Identify errors + error_task = progress.add_task(f"[{primary}]Identifying errors...", total=100) + errors = engine.identify_errors(parsed_logs) + progress.update(error_task, completed=100) + + # Step 4: Correlate errors (if enabled) + if correlate and len(errors) > 0: + correlate_task = progress.add_task(f"[{primary}]Correlating errors across services...", total=100) + correlator = ErrorCorrelator() + correlated = correlator.correlate(errors) + progress.update(correlate_task, completed=100) + else: + correlated = errors + + # Step 5: AI Analysis (if enabled) + if ai_analysis and len(correlated) > 0: + ai_task = progress.add_task(f"[{primary}]Running AI analysis...", total=100) + analysis_results = engine.ai_analyze( + errors=correlated[:max_errors], + context=parsed_logs, + ) + progress.update(ai_task, completed=100) + else: + analysis_results = None + + except Exception as e: + console.print(f"\n[{ui.theme.error}]Analysis failed: {e}[/]") + raise typer.Exit(1) + + # Display results + console.print() + _display_analysis_results( + parsed_logs=parsed_logs, + errors=correlated, + analysis=analysis_results, + output_format=output_format, + ) + + # Save report if requested + if save_report: + _save_report(save_report, parsed_logs, correlated, analysis_results) + console.print(f"\n[{ui.theme.success}]Report saved to {save_report}[/]") + + +@app.command("docker") +def analyze_docker( + containers: List[str] = typer.Argument( + ..., + help="Docker container names or IDs", + ), + follow: bool = typer.Option( + False, + "--follow", "-f", + help="Follow log output (live analysis)", + ), + tail: int = typer.Option( + 1000, + "--tail", "-n", + help="Number of lines to analyze from end", + ), + since: Optional[str] = typer.Option( + None, + "--since", + help="Show logs since timestamp or relative time", + ), + ai_analysis: bool = typer.Option( + True, + "--ai/--no-ai", + help="Enable AI-powered analysis", + ), +) -> None: + """ + Analyze Docker container logs. + + Examples: + debugai analyze docker my-api + debugai analyze docker api db redis --tail 500 + debugai analyze docker my-app --follow + """ + from debugai.ingestion.docker_ingester import DockerIngester + from debugai.core.engine import DebugEngine + + # Get theme colors + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + + console.print(Panel.fit( + f"[bold {primary}]Docker Log Analysis[/]\n" + f"[{dim_color}]Containers: {', '.join(containers)}[/]", + border_style=border + )) + + try: + ingester = DockerIngester() + engine = DebugEngine() + + with Progress( + SpinnerColumn(style=primary), + TextColumn(f"[{text_color}]" + "{task.description}" + f"[/{text_color}]"), + console=console, + ) as progress: + task = progress.add_task(f"[{primary}]Fetching Docker logs...", total=None) + + all_logs = [] + for container in containers: + logs = ingester.ingest( + container=container, + tail=tail, + since=since, + ) + all_logs.extend(logs) + + progress.update(task, description=f"[{primary}]Analyzing...") + + parsed = engine.parse_logs(all_logs) + errors = engine.identify_errors(parsed) + + if ai_analysis and errors: + analysis = engine.ai_analyze(errors, parsed) + else: + analysis = None + + _display_analysis_results(parsed, errors, analysis, OutputFormat.RICH) + + except Exception as e: + console.print(f"[{ui.theme.error}]Docker analysis failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("stream") +def analyze_stream( + source: str = typer.Argument( + ..., + help="Stream source (stdin, file path, or URL)", + ), + buffer_size: int = typer.Option( + 100, + "--buffer", + help="Number of lines to buffer before analysis", + ), + alert_level: str = typer.Option( + "error", + "--alert", + help="Alert threshold level (error, warn, info)", + ), +) -> None: + """ + Analyze streaming logs in real-time. + + Examples: + tail -f /var/log/app.log | debugai analyze stream stdin + debugai analyze stream /var/log/app.log --buffer 50 + """ + from debugai.ingestion.stream_ingester import StreamIngester + from debugai.core.engine import DebugEngine + + # Get theme colors + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + warning_color = ui.theme.warning + + console.print(Panel.fit( + f"[bold {primary}]Live Log Analysis[/]\n" + f"[{dim_color}]Source: {source} | Buffer: {buffer_size} | Alert: {alert_level}[/]", + border_style=border + )) + console.print(f"[{warning_color}]Press Ctrl+C to stop[/]\n") + + try: + ingester = StreamIngester() + engine = DebugEngine() + + for batch in ingester.stream(source, buffer_size): + parsed = engine.parse_logs(batch) + errors = engine.identify_errors(parsed) + + for error in errors: + if _should_alert(error, alert_level): + _display_live_error(error) + + except KeyboardInterrupt: + console.print(f"\n[{warning_color}]Stream analysis stopped[/]") + + +def _display_analysis_results( + parsed_logs: list, + errors: list, + analysis: Optional[dict], + output_format: OutputFormat, +) -> None: + """Display analysis results in the specified format.""" + + # Get theme colors + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + warning_color = ui.theme.warning + success_color = ui.theme.success + + # Summary panel + summary = Table(show_header=False, box=None, padding=(0, 2)) + summary.add_column("Metric", style=primary) + summary.add_column("Value", style=f"bold {text_color}") + + summary.add_row("Total Log Entries", str(len(parsed_logs))) + summary.add_row("Errors Found", f"[{error_color}]{len([e for e in errors if e.get('level') == 'error'])}[/]") + summary.add_row("Warnings Found", f"[{warning_color}]{len([e for e in errors if e.get('level') == 'warn'])}[/]") + + if analysis: + summary.add_row("Root Causes Identified", str(len(analysis.get('root_causes', [])))) + summary.add_row("Suggestions Generated", str(len(analysis.get('suggestions', [])))) + + console.print(Panel(summary, title=f"[{primary}]Analysis Summary[/]", border_style=border)) + + # Error details + if errors: + console.print(f"\n[bold {primary}]Errors Detected[/]\n") + + error_table = Table(show_header=True, header_style=f"bold {primary}") + error_table.add_column("#", style=dim_color, width=4) + error_table.add_column("Time", style=primary, width=20) + error_table.add_column("Service", style=text_color, width=15) + error_table.add_column("Error", style=error_color) + + for i, error in enumerate(errors[:10], 1): + error_table.add_row( + str(i), + error.get("timestamp", "Unknown"), + error.get("service", "Unknown"), + error.get("message", "Unknown error")[:60] + "..." + ) + + console.print(error_table) + + if len(errors) > 10: + console.print(f"\n[{dim_color}]... and {len(errors) - 10} more errors[/]") + + # AI Analysis results + if analysis: + console.print(f"\n[bold {primary}]AI Analysis[/]\n") + + # Root causes + if analysis.get('root_causes'): + console.print(f"[bold {text_color}]Root Cause Analysis:[/]") + for i, cause in enumerate(analysis['root_causes'], 1): + console.print(Panel( + f"[bold {text_color}]{cause['title']}[/]\n\n" + f"[{text_color}]{cause['explanation']}[/]\n\n" + f"[{dim_color}]Confidence: {cause['confidence']}%[/]", + title=f"[{primary}]Root Cause #{i}[/]", + border_style=border + )) + + # Suggestions + if analysis.get('suggestions'): + console.print(f"\n[bold {text_color}]Suggested Fixes:[/]") + for i, suggestion in enumerate(analysis['suggestions'], 1): + console.print(f"\n [{primary}]{i}.[/] [{text_color}]{suggestion['title']}[/]") + console.print(f" [{dim_color}]{suggestion['description']}[/]") + + if suggestion.get('code'): + console.print() + console.print(Syntax( + suggestion['code'], + suggestion.get('language', 'python'), + theme="monokai", + line_numbers=True, + word_wrap=True, + )) + + # Plain English summary + if analysis.get('summary'): + console.print(Panel( + f"[{text_color}]{analysis['summary']}[/]", + title=f"[{primary}]What Happened (Plain English)[/]", + border_style=border + )) + + +def _save_report(path: Path, logs: list, errors: list, analysis: Optional[dict]) -> None: + """Save analysis report to file.""" + import json + + report = { + "generated_at": str(Path), + "total_logs": len(logs), + "total_errors": len(errors), + "errors": errors, + "analysis": analysis, + } + + with open(path, "w") as f: + json.dump(report, f, indent=2, default=str) + + +def _should_alert(error: dict, threshold: str) -> bool: + """Check if error meets alert threshold.""" + levels = {"debug": 0, "info": 1, "warn": 2, "error": 3, "critical": 4} + error_level = levels.get(error.get("level", "info"), 1) + threshold_level = levels.get(threshold, 2) + return error_level >= threshold_level + + +def _display_live_error(error: dict) -> None: + """Display error in live streaming mode.""" + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + + console.print(Panel( + f"[bold {error_color}]{error.get('message', 'Unknown error')}[/]\n" + f"[{dim_color}]Service: {error.get('service', 'Unknown')} | " + f"Time: {error.get('timestamp', 'Unknown')}[/]", + border_style=border + )) diff --git a/src/debugai/cli/commands/config.py b/src/debugai/cli/commands/config.py new file mode 100644 index 0000000..c80d48a --- /dev/null +++ b/src/debugai/cli/commands/config.py @@ -0,0 +1,144 @@ +""" +Config Command - Manage DebugAI settings +""" + +from typing import Optional +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="config", help="Configure DebugAI settings", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def config_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Configure DebugAI settings.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai config", + description="Configure DebugAI settings. Manage API keys, models, and preferences.", + subcommands=[ + ("set", "Set a configuration value"), + ("get", "Get a configuration value"), + ("list", "List all configuration values"), + ], + examples=[ + "debugai config set api-key YOUR_API_KEY", + "debugai config set model gemini-2.0-flash", + "debugai config get api-key", + "debugai config list", + ] + ) + raise typer.Exit(0) + + +@app.command("set") +def config_set( + key: str = typer.Argument(..., help="Configuration key (e.g., api-key, model)"), + value: str = typer.Argument(..., help="Configuration value"), +) -> None: + """Set a configuration value.""" + from debugai.config.settings import Settings + + primary = ui.theme.primary + error_color = ui.theme.error + success_color = ui.theme.success + + try: + settings = Settings() + settings.set(key, value) + + # Mask sensitive values + display_value = "***" if "key" in key.lower() else value + console.print(f"[{success_color}]Set {key} = {display_value}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("get") +def config_get( + key: str = typer.Argument(..., help="Configuration key to get"), +) -> None: + """Get a configuration value.""" + from debugai.config.settings import Settings + + primary = ui.theme.primary + text_color = ui.theme.text + warning_color = ui.theme.warning + error_color = ui.theme.error + + try: + settings = Settings() + value = settings.get(key) + + if value is None: + console.print(f"[{warning_color}]Key '{key}' not set[/]") + else: + display_value = "***" if "key" in key.lower() else value + console.print(f"[{primary}]{key}[/] = [{text_color}]{display_value}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("list") +def config_list() -> None: + """List all configuration values.""" + from debugai.config.settings import Settings + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + error_color = ui.theme.error + + try: + settings = Settings() + all_config = settings.list_all() + + table = Table(show_header=True, header_style=f"bold {primary}") + table.add_column("Key", style=primary) + table.add_column("Value", style=text_color) + table.add_column("Source", style=dim_color) + + for item in all_config: + value = "***" if "key" in item["key"].lower() else item["value"] + table.add_row(item["key"], str(value), item["source"]) + + console.print(table) + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("reset") +def config_reset( + confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), +) -> None: + """Reset all configuration to defaults.""" + from debugai.config.settings import Settings + + success_color = ui.theme.success + warning_color = ui.theme.warning + + if not confirm: + confirm = typer.confirm("Reset all configuration?") + + if confirm: + settings = Settings() + settings.reset() + console.print(f"[{success_color}]Configuration reset to defaults[/]") + else: + console.print(f"[{warning_color}]Cancelled[/]") diff --git a/src/debugai/cli/commands/explain.py b/src/debugai/cli/commands/explain.py new file mode 100644 index 0000000..9b3fcea --- /dev/null +++ b/src/debugai/cli/commands/explain.py @@ -0,0 +1,127 @@ +""" +Explain Command - Get plain English explanations for errors +""" + +from typing import Optional +import typer +from rich.console import Console +from rich.panel import Panel +from rich.markdown import Markdown +from rich import box + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="explain", help="Explain errors in plain English", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def explain_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Explain errors in plain English with AI assistance.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai explain", + description="Get plain English explanations for errors using AI. Understand what went wrong and why it happened.", + subcommands=[ + ("error", "Explain a specific error by ID or hash"), + ("trace", "Explain a full stack trace"), + ("message", "Explain any error message directly"), + ], + examples=[ + "debugai explain error err_12345", + "debugai explain error err_12345 --verbose", + "debugai explain trace ./stacktrace.txt", + 'debugai explain message "NullPointerException"', + ] + ) + raise typer.Exit(0) + + +@app.command("error") +def explain_error( + error_id: str = typer.Argument(..., help="Error ID or hash to explain"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed explanation"), + show_code: bool = typer.Option(True, "--code/--no-code", help="Show relevant code"), +) -> None: + """Explain a specific error in plain English.""" + from debugai.core.engine import DebugEngine + from debugai.ai.gemini_client import GeminiClient + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + warning_color = ui.theme.warning + success_color = ui.theme.success + + console.print(f"\n[{primary}]Looking up error: {error_id}[/]\n") + + try: + engine = DebugEngine() + ai = GeminiClient() + + error = engine.get_error_by_id(error_id) + if not error: + console.print(f"[{error_color}]Error not found: {error_id}[/]") + raise typer.Exit(1) + + with console.status(f"[bold {primary}]Generating explanation..."): + explanation = ai.explain_error(error, verbose=verbose) + + console.print(Panel( + Markdown(explanation["summary"]), + title=f"[{primary}]What Happened[/]", + border_style=border + )) + + if verbose and explanation.get("technical_details"): + console.print(Panel( + explanation["technical_details"], + title=f"[{primary}]Technical Details[/]", + border_style=border + )) + + if explanation.get("similar_issues"): + console.print(f"\n[bold {text_color}]Similar Issues:[/]") + for issue in explanation["similar_issues"][:3]: + console.print(f" - [{text_color}]{issue['title']}[/] - [{dim_color}]{issue['url']}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed to explain error: {e}[/]") + raise typer.Exit(1) + + +@app.command("text") +def explain_text( + error_text: str = typer.Argument(..., help="Error message or stack trace to explain"), +) -> None: + """Explain any error text directly.""" + from debugai.ai.gemini_client import GeminiClient + + primary = ui.theme.primary + border = ui.theme.border + error_color = ui.theme.error + + console.print(f"\n[{primary}]Analyzing error text...[/]\n") + + try: + ai = GeminiClient() + + with console.status(f"[bold {primary}]Generating explanation..."): + explanation = ai.explain_text(error_text) + + console.print(Panel( + Markdown(explanation), + title=f"[{primary}]Explanation[/]", + border_style=border + )) + + except Exception as e: + console.print(f"[{error_color}]Failed to explain: {e}[/]") + raise typer.Exit(1) diff --git a/src/debugai/cli/commands/interactive.py b/src/debugai/cli/commands/interactive.py new file mode 100644 index 0000000..c2dd6a8 --- /dev/null +++ b/src/debugai/cli/commands/interactive.py @@ -0,0 +1,127 @@ +""" +Interactive Command - Interactive debugging session +""" + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="interactive", help="Interactive debugging session", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def interactive_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Interactive debugging session.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai interactive", + description="Start an interactive debugging session. Chat with AI to analyze logs and debug issues in real-time.", + subcommands=[ + ("start", "Start an interactive debugging session"), + ], + examples=[ + "debugai interactive start", + ] + ) + raise typer.Exit(0) + + +@app.command("start") +def start_session() -> None: + """Start an interactive debugging session.""" + from debugai.core.engine import DebugEngine + from debugai.ai.gemini_client import GeminiClient + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + warning_color = ui.theme.warning + success_color = ui.theme.success + + console.print(Panel.fit( + f"[bold {primary}]Interactive Debug Session[/]\n\n" + f"[{text_color}]Commands:[/]\n" + f" [{primary}]analyze [/] - Analyze log file\n" + f" [{primary}]explain [/] - Explain an error\n" + f" [{primary}]suggest [/] - Get fix suggestions\n" + f" [{primary}]history[/] - Show session history\n" + f" [{primary}]clear[/] - Clear screen\n" + f" [{primary}]exit[/] - Exit session\n", + border_style=border + )) + + engine = DebugEngine() + ai = GeminiClient() + history = [] + + while True: + try: + command = Prompt.ask(f"\n[bold {primary}]debugai>[/]") + + if not command.strip(): + continue + + parts = command.strip().split(maxsplit=1) + cmd = parts[0].lower() + args = parts[1] if len(parts) > 1 else "" + + if cmd == "exit" or cmd == "quit": + console.print(f"[{warning_color}]Goodbye![/]") + break + + elif cmd == "clear": + console.clear() + + elif cmd == "history": + if history: + for i, h in enumerate(history[-10:], 1): + console.print(f" [{text_color}]{i}. {h}[/]") + else: + console.print(f"[{dim_color}]No history yet[/]") + + elif cmd == "analyze": + if not args: + console.print(f"[{error_color}]Usage: analyze [/]") + else: + with console.status(f"[{primary}]Analyzing..."): + # Quick analysis + result = engine.quick_analyze(args) + console.print(f"[{text_color}]Found {result['error_count']} errors[/]") + + elif cmd == "explain": + if not args: + console.print(f"[{error_color}]Usage: explain [/]") + else: + with console.status(f"[{primary}]Explaining..."): + explanation = ai.explain_text(args) + console.print(Panel(explanation, title=f"[{primary}]Explanation[/]", border_style=border)) + + elif cmd == "suggest": + if not args: + console.print(f"[{error_color}]Usage: suggest [/]") + else: + with console.status(f"[{primary}]Generating suggestions..."): + suggestions = ai.suggest_for_text(args) + for s in suggestions: + console.print(f"[bold {text_color}]{s['title']}[/]: [{text_color}]{s['description']}[/]") + + else: + console.print(f"[{error_color}]Unknown command: {cmd}[/]") + + history.append(command) + + except KeyboardInterrupt: + console.print(f"\n[{warning_color}]Use 'exit' to quit[/]") + except Exception as e: + console.print(f"[{error_color}]Error: {e}[/]") diff --git a/src/debugai/cli/commands/logs.py b/src/debugai/cli/commands/logs.py new file mode 100644 index 0000000..38cee7e --- /dev/null +++ b/src/debugai/cli/commands/logs.py @@ -0,0 +1,169 @@ +""" +Logs Command - Manage log sources +""" + +from pathlib import Path +from typing import Optional +import typer +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich import box + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="logs", help="Manage log sources", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def logs_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Manage log sources for DebugAI analysis.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai logs", + description="Manage log sources for DebugAI analysis. Add, remove, list, and watch log sources for real-time monitoring.", + subcommands=[ + ("add", "Add a new log source (file or directory)"), + ("list", "List all configured log sources"), + ("remove", "Remove a log source by name"), + ("watch", "Watch log sources for new entries in real-time"), + ], + examples=[ + "debugai logs add ./logs --name app-logs", + "debugai logs list", + "debugai logs remove app-logs", + "debugai logs watch", + ] + ) + raise typer.Exit(0) + + +@app.command("add") +def add_source( + path: Path = typer.Argument(..., help="Path to log file or directory"), + name: str = typer.Option(None, "--name", "-n", help="Name for this source"), + pattern: str = typer.Option("*.log", "--pattern", "-p", help="File pattern"), + service: str = typer.Option(None, "--service", "-s", help="Service name"), +) -> None: + """Add a log source.""" + from debugai.storage.database import Database + + primary = ui.theme.primary + text_color = ui.theme.text + success_color = ui.theme.success + error_color = ui.theme.error + + try: + db = Database() + source_name = name or path.name + + db.add_log_source( + name=source_name, + path=str(path.absolute()), + pattern=pattern, + service=service + ) + + console.print(f"[{success_color}]Added log source: {source_name}[/]") + console.print(f" [{text_color}]Path: {path.absolute()}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("list") +def list_sources() -> None: + """List all log sources.""" + from debugai.storage.database import Database + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + warning_color = ui.theme.warning + error_color = ui.theme.error + + try: + db = Database() + sources = db.get_log_sources() + + if not sources: + console.print(f"[{warning_color}]No log sources configured[/]") + console.print(f"[{dim_color}]Use 'debugai logs add ./path' to add one[/]") + return + + table = Table(show_header=True, header_style=f"bold {primary}") + table.add_column("Name", style=primary) + table.add_column("Path", style=text_color) + table.add_column("Pattern", style=dim_color) + table.add_column("Service", style=text_color) + + for source in sources: + table.add_row( + source["name"], + source["path"], + source["pattern"], + source.get("service", "-") + ) + + console.print(table) + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("remove") +def remove_source( + name: str = typer.Argument(..., help="Name of source to remove"), +) -> None: + """Remove a log source.""" + from debugai.storage.database import Database + + success_color = ui.theme.success + error_color = ui.theme.error + + try: + db = Database() + db.remove_log_source(name) + console.print(f"[{success_color}]Removed: {name}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("watch") +def watch_sources( + name: Optional[str] = typer.Argument(None, help="Source name to watch (all if omitted)"), +) -> None: + """Watch log sources for new entries.""" + primary = ui.theme.primary + text_color = ui.theme.text + warning_color = ui.theme.warning + error_color = ui.theme.error + + console.print(f"[{primary}]Watching logs... (Ctrl+C to stop)[/]\n") + + try: + from debugai.ingestion.stream_ingester import StreamIngester + + ingester = StreamIngester() + for entry in ingester.watch(name): + level = entry.get("level", "info") + if level == "error": + level_color = error_color + elif level == "warn": + level_color = warning_color + else: + level_color = text_color + console.print(f"[{level_color}]{entry['message']}[/]") + + except KeyboardInterrupt: + console.print(f"\n[{warning_color}]Stopped watching[/]") diff --git a/src/debugai/cli/commands/suggest.py b/src/debugai/cli/commands/suggest.py new file mode 100644 index 0000000..6cf1c65 --- /dev/null +++ b/src/debugai/cli/commands/suggest.py @@ -0,0 +1,134 @@ +""" +Suggest-Fix Command - AI-powered fix suggestions +""" + +from typing import Optional +import typer +from rich.console import Console +from rich.panel import Panel +from rich.syntax import Syntax +from rich import box + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="suggest-fix", help="Get AI-powered fix suggestions", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def suggest_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Get AI-powered fix suggestions for errors.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai suggest-fix", + description="Get AI-powered fix suggestions for errors and issues. Receive actionable code fixes with confidence scores.", + subcommands=[ + ("error", "Get fix suggestions for a specific error"), + ("message", "Get suggestions for any error message"), + ("pattern", "Get suggestions for recurring error patterns"), + ], + examples=[ + "debugai suggest-fix error err_12345", + "debugai suggest-fix error err_12345 --max 5", + 'debugai suggest-fix message "OutOfMemoryError"', + "debugai suggest-fix pattern null_pointer", + ] + ) + raise typer.Exit(0) + + +@app.command("error") +def suggest_for_error( + error_id: str = typer.Argument(..., help="Error ID to get suggestions for"), + max_suggestions: int = typer.Option(3, "--max", "-m", help="Maximum suggestions"), +) -> None: + """Get fix suggestions for a specific error.""" + from debugai.core.engine import DebugEngine + from debugai.ai.gemini_client import GeminiClient + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + success_color = ui.theme.success + + console.print(f"\n[{primary}]Getting suggestions for: {error_id}[/]\n") + + try: + engine = DebugEngine() + ai = GeminiClient() + + error = engine.get_error_by_id(error_id) + if not error: + console.print(f"[{error_color}]Error not found: {error_id}[/]") + raise typer.Exit(1) + + with console.status(f"[bold {primary}]Generating suggestions..."): + suggestions = ai.suggest_fixes(error, max_suggestions=max_suggestions) + + for i, suggestion in enumerate(suggestions, 1): + console.print(Panel( + f"[bold {text_color}]{suggestion['title']}[/]\n\n" + f"[{text_color}]{suggestion['description']}[/]\n\n" + f"[{dim_color}]Confidence: {suggestion['confidence']}%[/]", + title=f"[{primary}]Suggestion #{i}[/]", + border_style=border + )) + + if suggestion.get("code"): + console.print(Syntax( + suggestion["code"], + suggestion.get("language", "python"), + theme="monokai", + line_numbers=True + )) + console.print() + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("text") +def suggest_for_text( + error_text: str = typer.Argument(..., help="Error message to get suggestions for"), + language: str = typer.Option("python", "--lang", "-l", help="Programming language"), +) -> None: + """Get fix suggestions for any error text.""" + from debugai.ai.gemini_client import GeminiClient + + primary = ui.theme.primary + text_color = ui.theme.text + border = ui.theme.border + error_color = ui.theme.error + + console.print(f"\n[{primary}]Analyzing: {error_text[:50]}...[/]\n") + + try: + ai = GeminiClient() + + with console.status(f"[bold {primary}]Generating suggestions..."): + suggestions = ai.suggest_for_text(error_text, language=language) + + for i, suggestion in enumerate(suggestions, 1): + console.print(Panel( + f"[bold {text_color}]{suggestion['title']}[/]\n\n[{text_color}]{suggestion['description']}[/]", + title=f"[{primary}]Suggestion #{i}[/]", + border_style=border + )) + + if suggestion.get("code"): + console.print(Syntax( + suggestion["code"], language, theme="monokai", line_numbers=True + )) + console.print() + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) diff --git a/src/debugai/cli/commands/timeline.py b/src/debugai/cli/commands/timeline.py new file mode 100644 index 0000000..a904a46 --- /dev/null +++ b/src/debugai/cli/commands/timeline.py @@ -0,0 +1,158 @@ +""" +Timeline Command - Generate event timeline +""" + +from typing import Optional +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from debugai.ui import get_ui +from debugai.cli.help_theme import show_command_help + +console = Console() +ui = get_ui() +app = typer.Typer(name="timeline", help="Generate event timeline", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def timeline_callback( + ctx: typer.Context, + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Generate and display event timelines.""" + if help_flag or ctx.invoked_subcommand is None: + show_command_help( + command_name="debugai timeline", + description="Generate and display event timelines. Visualize log events chronologically to understand incident flow.", + subcommands=[ + ("show", "Show timeline of events with filtering options"), + ], + examples=[ + "debugai timeline show", + "debugai timeline show --last 1h", + "debugai timeline show --filter errors", + "debugai timeline show --service api --limit 100", + ] + ) + raise typer.Exit(0) + + +@app.command("show") +def show_timeline( + last: str = typer.Option("5m", "--last", "-l", help="Time range (e.g., 5m, 1h, 1d)"), + filter_level: Optional[str] = typer.Option(None, "--filter", "-f", help="Filter: errors, warnings, all"), + service: Optional[str] = typer.Option(None, "--service", "-s", help="Filter by service"), + limit: int = typer.Option(50, "--limit", "-n", help="Max events to show"), +) -> None: + """Show timeline of events.""" + from debugai.core.engine import DebugEngine + from debugai.analysis.timeline_builder import TimelineBuilder + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + warning_color = ui.theme.warning + + console.print(f"\n[{primary}]Timeline (last {last})[/]\n") + + try: + engine = DebugEngine() + builder = TimelineBuilder() + + with console.status(f"[bold {primary}]Building timeline..."): + events = builder.build( + time_range=last, + filter_level=filter_level, + service=service, + limit=limit + ) + + if not events: + console.print(f"[{warning_color}]No events found in the specified range[/]") + return + + table = Table(show_header=True, header_style=f"bold {primary}") + table.add_column("Time", style=primary, width=20) + table.add_column("Level", width=8) + table.add_column("Service", style=text_color, width=15) + table.add_column("Event", style=text_color) + + for event in events: + level = event["level"] + if level == "error": + level_color = error_color + elif level == "warn": + level_color = warning_color + else: + level_color = dim_color + table.add_row( + event["timestamp"], + f"[{level_color}]{level.upper()}[/]", + event.get("service", "-"), + event["message"][:60] + ("..." if len(event["message"]) > 60 else "") + ) + + console.print(table) + console.print(f"\n[{dim_color}]Showing {len(events)} events[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) + + +@app.command("crash") +def crash_timeline( + error_id: str = typer.Argument(..., help="Error ID to trace"), + before: str = typer.Option("5m", "--before", "-b", help="Time before crash to show"), +) -> None: + """Show events leading to a crash.""" + from debugai.core.engine import DebugEngine + from debugai.analysis.timeline_builder import TimelineBuilder + + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + error_color = ui.theme.error + warning_color = ui.theme.warning + + console.print(f"\n[{primary}]Crash Timeline for: {error_id}[/]\n") + + try: + engine = DebugEngine() + builder = TimelineBuilder() + + error = engine.get_error_by_id(error_id) + if not error: + console.print(f"[{error_color}]Error not found: {error_id}[/]") + raise typer.Exit(1) + + with console.status(f"[bold {primary}]Tracing events..."): + events = builder.trace_crash(error, before=before) + + console.print(Panel( + f"[bold {error_color}]CRASH[/]: [{text_color}]{error['message']}[/]\n" + f"[{dim_color}]Time: {error['timestamp']} | Service: {error.get('service', 'Unknown')}[/]", + border_style=border + )) + + console.print(f"\n[bold {text_color}]Events leading to crash:[/]\n") + + for i, event in enumerate(events): + prefix = "ā”œā”€ā”€" if i < len(events) - 1 else "└──" + level = event["level"] + if level == "error": + level_color = error_color + elif level == "warn": + level_color = warning_color + else: + level_color = dim_color + console.print(f" {prefix} [{level_color}]{event['timestamp']}[/] [{text_color}]{event['message']}[/]") + + except Exception as e: + console.print(f"[{error_color}]Failed: {e}[/]") + raise typer.Exit(1) diff --git a/src/debugai/cli/help_theme.py b/src/debugai/cli/help_theme.py new file mode 100644 index 0000000..84349d2 --- /dev/null +++ b/src/debugai/cli/help_theme.py @@ -0,0 +1,193 @@ +""" +Themed Help for Sub-commands and Single Commands +""" + +import sys +import time +import random +from typing import List, Tuple, Optional +from rich.console import Console +from rich.box import ROUNDED +from rich.table import Table +from rich.panel import Panel +from rich.live import Live +from rich.text import Text + +from debugai.ui import get_ui + +console = Console() + +# ASCII Art Banner +BANNER = """ + ____ _____ ____ _ _ ____ _ ___ +| _ \\| ____| __ )| | | |/ ___| / \\ |_ _| +| | | | _| | _ \\| | | | | _ / _ \\ | | +| |_| | |___| |_) | |_| | |_| |/ ___ \\ | | +|____/|_____|____/ \\___/ \\____/_/ \\_\\___| +""" + + +def _show_banner_animation(primary: str) -> None: + """Display ASCII banner with glitch animation.""" + glitch_chars = ['@', '#', '$', '%', '&', '*', '!', '?', '+', '='] + banner_lines = BANNER.strip().split('\n') + + # Glitch animation + try: + with Live(console=console, refresh_per_second=30, transient=True) as live: + # Glitch reveal + for frame in range(8): + glitch_text = Text() + for line in banner_lines: + glitch_line = "" + for char in line: + if char != ' ' and random.random() < (0.7 - frame * 0.1): + glitch_line += random.choice(glitch_chars) + else: + glitch_line += char + glitch_text.append(glitch_line + "\n", style=f"bold {primary}") + live.update(glitch_text) + time.sleep(0.05) + + # Stable banner + final_text = Text() + for line in banner_lines: + final_text.append(line + "\n", style=f"bold {primary}") + live.update(final_text) + time.sleep(0.1) + except Exception: + pass + + # Print stable banner after animation + for line in banner_lines: + console.print(f"[bold {primary}]{line}[/]") + + +def show_command_help( + command_name: str, + description: str, + subcommands: List[Tuple[str, str]], + examples: Optional[List[str]] = None, + options: Optional[List[Tuple[str, str, str]]] = None, +) -> None: + """Display themed help for a command group (no ASCII banner). + + Args: + command_name: Name of the command (e.g., "debugai logs", "debugai analyze") + description: Description of the command + subcommands: List of (name, description) tuples for sub-commands + examples: List of example commands + options: List of (option, short, description) tuples for options + """ + ui = get_ui() + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + + console.print() + + # Build help content + help_parts = [] + + # Description + help_parts.append(f"[bold {primary}]DESCRIPTION[/]") + help_parts.append(f" {description}") + help_parts.append("") + + # Commands + if subcommands: + help_parts.append(f"[bold {primary}]COMMANDS[/]") + max_cmd_len = max(len(cmd) for cmd, _ in subcommands) + for cmd, desc in subcommands: + help_parts.append(f" [{primary}]{cmd:<{max_cmd_len}}[/] {desc}") + help_parts.append("") + + # Examples + if examples: + help_parts.append(f"[bold {primary}]EXAMPLES[/]") + for example in examples: + help_parts.append(f" [{dim_color}]$[/] {example}") + help_parts.append("") + + # Options + help_parts.append(f"[bold {primary}]OPTIONS[/]") + help_parts.append(f" [{text_color}]-h, --help[/] Show this help message") + + help_text = "\n".join(help_parts) + + console.print(Panel( + help_text, + title=f"[bold {primary}]{command_name}[/]", + border_style=border, + box=ROUNDED, + padding=(1, 2) + )) + console.print() + + +def show_single_command_help( + command_name: str, + description: str, + options: Optional[List[Tuple[str, str]]] = None, + examples: Optional[List[str]] = None, +) -> None: + """Display themed help for a single command (no ASCII banner). + + Args: + command_name: Full command name (e.g., "debugai init") + description: Description of what the command does + options: List of (option_flags, description) tuples + examples: List of example usage strings + """ + ui = get_ui() + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + + console.print() + + # Build help content + help_parts = [] + + # Description + help_parts.append(f"[bold {primary}]DESCRIPTION[/]") + help_parts.append(f" {description}") + help_parts.append("") + + # Usage + help_parts.append(f"[bold {primary}]USAGE[/]") + help_parts.append(f" [{dim_color}]$[/] {command_name} [OPTIONS]") + help_parts.append("") + + # Examples + if examples: + help_parts.append(f"[bold {primary}]EXAMPLES[/]") + for example in examples: + help_parts.append(f" [{dim_color}]$[/] {example}") + help_parts.append("") + + # Options + help_parts.append(f"[bold {primary}]OPTIONS[/]") + if options: + max_opt_len = max(len(opt) for opt, _ in options) + for opt, desc in options: + help_parts.append(f" [{text_color}]{opt:<{max_opt_len}}[/] {desc}") + help_parts.append(f" [{text_color}]-h, --help[/] Show this help message") + + help_text = "\n".join(help_parts) + + console.print(Panel( + help_text, + title=f"[bold {primary}]{command_name}[/]", + border_style=border, + box=ROUNDED, + padding=(1, 2) + )) + console.print() + + +def check_help_flag() -> bool: + """Check if --help or -h is in the command line arguments.""" + return "--help" in sys.argv or "-h" in sys.argv diff --git a/src/debugai/cli/main.py b/src/debugai/cli/main.py new file mode 100644 index 0000000..5dc7df2 --- /dev/null +++ b/src/debugai/cli/main.py @@ -0,0 +1,433 @@ +""" +DebugAI CLI - Main Entry Point + +This module defines the main CLI application with all commands. +""" + +import sys +import typer +from rich.console import Console +from rich.panel import Panel +from rich import print as rprint + +from debugai.cli.commands import analyze, explain, suggest, timeline, config, logs, interactive +from debugai import __version__ +from debugai.ui import UIEffects, Themes, DEBUGAI_BANNER, get_ui, get_saved_theme, THEME_MAP + +# Parse theme from command line arguments early (before Typer processes them) +def _get_theme_from_args(): + """Extract theme from command line args before Typer runs. + + If --theme is provided in args, use that. + Otherwise, load from saved preference. + """ + args = sys.argv[1:] + + # Check if theme is explicitly provided in args + for i, arg in enumerate(args): + if arg in ("--theme", "-t") and i + 1 < len(args): + theme_name = args[i + 1].lower() + return THEME_MAP.get(theme_name, get_saved_theme()) + elif arg.startswith("--theme="): + theme_name = arg.split("=", 1)[1].lower() + return THEME_MAP.get(theme_name, get_saved_theme()) + + # No theme in args, use saved preference + return get_saved_theme() + +# Initialize console and UI with the theme from args or saved preference +console = Console() +_initial_theme = _get_theme_from_args() +ui = get_ui(_initial_theme) + +# Get dynamic help text based on current theme +def _get_help_text(): + """Generate help text with current theme colors.""" + primary = ui.theme.primary + secondary = ui.theme.secondary + dim = ui.theme.dim + + return f"""DebugAI - AI-Powered Log Analysis & Debugging CLI + +Reduce debugging time by 60-75% with intelligent log analysis, +error correlation, and AI-powered fix suggestions. + +[bold {primary}]Quick Start:[/] + + $ debugai init # Initialize in current directory + $ debugai config set api-key YOUR_KEY # Set Gemini API key + $ debugai analyze ./logs # Analyze log files + $ debugai explain error_12345 # Explain an error + $ debugai suggest-fix "NullPointerException" # Get fix suggestions + $ debugai timeline --last 5m # View recent events + +[bold {primary}]Examples:[/] + + # Analyze logs from multiple services + $ debugai analyze ./logs --service api,db,redis + + # Get plain English explanation of an error + $ debugai explain err_abc123 --verbose + + # Generate timeline of events leading to crash + $ debugai timeline --last 10m --filter errors + +[{dim}]Run 'debugai COMMAND --help' for more information on a command.[/]""" + +# Create main Typer app +app = typer.Typer( + name="debugai", + help="AI-Powered Log Analysis & Debugging CLI - Reduce debugging time by 60-75%", + add_completion=True, + no_args_is_help=False, # We handle no-args ourselves for custom output + rich_markup_mode="rich", + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, +) + +# Register command groups +app.add_typer(analyze.app, name="analyze", help="Analyze logs from multiple sources") +app.add_typer(explain.app, name="explain", help="Explain errors in plain English") +app.add_typer(suggest.app, name="suggest-fix", help="Get AI-powered fix suggestions") +app.add_typer(timeline.app, name="timeline", help="Generate event timeline") +app.add_typer(config.app, name="config", help="Configure DebugAI settings") +app.add_typer(logs.app, name="logs", help="Manage log sources") +app.add_typer(interactive.app, name="interactive", help="Interactive debugging session") + +# Import themed help for single commands +from debugai.cli.help_theme import show_single_command_help + + +@app.command() +def version( + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Display DebugAI version and system information.""" + if help_flag: + show_single_command_help( + command_name="debugai version", + description="Display DebugAI version and system information including Python version, platform, and architecture.", + examples=["debugai version"] + ) + raise typer.Exit(0) + + import platform + import sys + + ui.banner(DEBUGAI_BANNER, animate=True) + print() + ui.subheader("System Information") + print() + ui.status_line("Version", f"v{__version__}", "ok") + ui.status_line("Python", sys.version.split()[0], "info") + ui.status_line("Platform", f"{platform.system()} {platform.release()}", "info") + ui.status_line("Architecture", platform.machine(), "info") + print() + ui.success("Ready to debug!") + + +@app.command() +def init( + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Initialize DebugAI in the current directory.""" + if help_flag: + show_single_command_help( + command_name="debugai init", + description="Initialize DebugAI in the current directory. Creates .debugai/ folder with configuration files, patterns directory, and cache.", + examples=[ + "debugai init", + "cd my-project && debugai init" + ] + ) + raise typer.Exit(0) + + from debugai.core.initializer import initialize_project + + print() + ui.glitch_text("Initializing DebugAI...", duration=0.4) + print() + + try: + result = initialize_project() + if result.success: + ui.success("DebugAI initialized successfully!") + print() + ui.subheader("Created") + ui.bullet_list([ + ".debugai/ - Configuration directory", + ".debugai/config.yaml - Main configuration", + ".debugai/patterns/ - Custom log patterns", + ".debugai/cache/ - Analysis cache" + ]) + print() + ui.subheader("Next Steps") + ui.numbered_list([ + "Configure your Gemini API key: debugai config set api-key YOUR_KEY", + "Add log sources: debugai logs add ./logs --name app-logs", + "Start analyzing: debugai analyze path ./logs" + ]) + print() + else: + ui.error(f"Initialization failed: {result.message}") + except Exception as e: + ui.error(f"Error during initialization: {e}") + raise typer.Exit(1) + + +@app.command() +def status( + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Check DebugAI status and configuration.""" + if help_flag: + show_single_command_help( + command_name="debugai status", + description="Check DebugAI status and configuration. Shows health status of all components including API connection, database, and log sources.", + examples=["debugai status"] + ) + raise typer.Exit(0) + + from debugai.core.status import get_status + + print() + ui.glitch_text("DebugAI Status", duration=0.3) + print() + + status_info = get_status() + + for component, info in status_info.items(): + status_type = "ok" if info["healthy"] else "error" + ui.status_line( + component, + info['status'], + status_type + ) + print() + + +@app.command() +def doctor( + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """Diagnose and fix common issues.""" + if help_flag: + show_single_command_help( + command_name="debugai doctor", + description="Diagnose and fix common issues. Runs system checks for Python version, dependencies, API key configuration, and database connectivity.", + examples=["debugai doctor"] + ) + raise typer.Exit(0) + + from debugai.core.doctor import run_diagnostics + + print() + ui.glitch_text("Running diagnostics...", duration=0.4) + print() + + ui.loading_animation("Checking system", duration=1.5) + + results = run_diagnostics() + + print() + for check in results: + if check["passed"]: + ui.success(check['name']) + else: + ui.error(check['name']) + ui.dim(f" Fix: {check['fix']}") + print() + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"), + debug: bool = typer.Option(False, "--debug", help="Enable debug mode"), + theme: str = typer.Option(None, "--theme", "-t", help="UI theme: cyan, green, red, purple, orange, blue, matrix, hacker (saves preference)"), + no_animation: bool = typer.Option(False, "--no-animation", help="Disable animations"), + help_flag: bool = typer.Option(False, "--help", "-h", help="Show this help message", is_eager=True), +) -> None: + """ + DebugAI - AI-Powered Log Analysis & Debugging CLI + """ + # Store global options in context + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["quiet"] = quiet + ctx.obj["debug"] = debug + + # If theme is explicitly provided, update and save it + if theme is not None: + if theme.lower() in THEME_MAP: + ui.set_theme(THEME_MAP[theme.lower()], save=True) + + # Handle animation setting + if no_animation or quiet: + ui.disable_animations() + + # Get theme colors for help + primary = ui.theme.primary + text_color = ui.theme.text + dim_color = ui.theme.dim + border = ui.theme.border + + # If help flag or no command provided, show custom themed help + if help_flag or ctx.invoked_subcommand is None: + from rich.box import ROUNDED + from rich.table import Table + from rich.live import Live + from rich.text import Text + + # ASCII Art Banner + banner = [ + "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•— ā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—", + "ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘", + "ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā•‘", + "ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘", + "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘", + "ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā• ā•šā•ā• ā•šā•ā•ā•šā•ā•", + ] + + console.print() + + # Animate the banner (always animate unless --no-animation or --quiet) + if ui.animations_enabled: + import time + import random + + glitch_chars = "ā–‘ā–’ā–“ā–ˆā–„ā–€ā–ā–Œ" + colors = [primary, "red", "green", "yellow", "magenta", "cyan"] + + def generate_frame(frame_num, total_frames): + """Generate a single animation frame.""" + lines = [] + progress = frame_num / total_frames + + for line in banner: + output = "" + for i, char in enumerate(line): + char_progress = i / len(line) + + if char == " ": + output += " " + elif progress > char_progress: + # Revealed - maybe glitch + if frame_num < total_frames - 3 and random.random() < 0.15 * (1 - progress): + output += random.choice(glitch_chars) + else: + output += char + else: + # Not revealed yet + if random.random() < 0.6: + output += random.choice(glitch_chars) + else: + output += " " + lines.append(output) + return lines + + total_frames = 20 + + with Live(console=console, refresh_per_second=30, transient=True) as live: + for frame in range(total_frames + 1): + frame_lines = generate_frame(frame, total_frames) + + # Pick color - more random early, stable at end + if frame < total_frames - 2 and random.random() < 0.3 * (1 - frame/total_frames): + color = random.choice(colors) + else: + color = primary + + # Create rich text + display = Text() + for line in frame_lines: + display.append(line + "\n", style=f"bold {color}") + + live.update(display) + time.sleep(0.04) + + # Print final banner (not transient) + for line in banner: + console.print(f"[bold {primary}]{line}[/]") + else: + # No animation, just print + for line in banner: + console.print(f"[bold {primary}]{line}[/]") + + console.print() + console.print(f"[{text_color}]AI-Powered Log Analysis & Debugging CLI[/]") + console.print(f"[{dim_color}]Reduce debugging time by 60-75%[/]") + console.print() + + # Options panel + options_table = Table(show_header=False, box=ROUNDED, border_style=border, + title=f"[{primary}]Options[/]", title_justify="left", + padding=(0, 1), expand=True) + options_table.add_column("Option", style=primary, width=20) + options_table.add_column("Short", style=primary, width=6) + options_table.add_column("Description", style=text_color) + + options_table.add_row("--theme", "-t", "Set UI theme (saves preference)") + options_table.add_row("--verbose", "-v", "Enable verbose output") + options_table.add_row("--quiet", "-q", "Suppress non-essential output") + options_table.add_row("--no-animation", "", "Disable animations") + options_table.add_row("--help", "-h", "Show this message and exit") + + console.print(options_table) + console.print() + + # Commands panel + commands_table = Table(show_header=False, box=ROUNDED, border_style=border, + title=f"[{primary}]Commands[/]", title_justify="left", + padding=(0, 1), expand=True) + commands_table.add_column("Command", style=primary, width=15) + commands_table.add_column("Description", style=text_color) + + commands_table.add_row("analyze", "Analyze logs from multiple sources") + commands_table.add_row("explain", "Explain errors in plain English") + commands_table.add_row("suggest-fix", "Get AI-powered fix suggestions") + commands_table.add_row("timeline", "Generate event timeline") + commands_table.add_row("config", "Configure DebugAI settings") + commands_table.add_row("logs", "Manage log sources") + commands_table.add_row("interactive", "Interactive debugging session") + commands_table.add_row("init", "Initialize DebugAI in current directory") + commands_table.add_row("status", "Check DebugAI status and configuration") + commands_table.add_row("doctor", "Diagnose and fix common issues") + commands_table.add_row("version", "Display version and system information") + + console.print(commands_table) + console.print() + + # Themes panel + themes_table = Table(show_header=False, box=ROUNDED, border_style=border, + title=f"[{primary}]Themes[/]", title_justify="left", + padding=(0, 1), expand=True) + themes_table.add_column("Themes", style=text_color) + themes_table.add_row("cyan, green, red, purple, orange, blue, matrix, hacker") + + console.print(themes_table) + console.print() + + # Quick Start panel + quick_table = Table(show_header=False, box=ROUNDED, border_style=border, + title=f"[{primary}]Quick Start[/]", title_justify="left", + padding=(0, 1), expand=True) + quick_table.add_column("Command", style=text_color) + quick_table.add_column("Description", style=dim_color) + + quick_table.add_row("debugai init", "Initialize DebugAI") + quick_table.add_row("debugai config set api-key YOUR_KEY", "Set Gemini API key") + quick_table.add_row("debugai analyze path ./logs", "Analyze log files") + + console.print(quick_table) + console.print() + + console.print(f"[{dim_color}]Powered by Google Gemini AI[/]") + console.print() + + raise typer.Exit(0) + + +if __name__ == "__main__": + app() diff --git a/src/debugai/config/__init__.py b/src/debugai/config/__init__.py new file mode 100644 index 0000000..32df154 --- /dev/null +++ b/src/debugai/config/__init__.py @@ -0,0 +1,5 @@ +"""Config Package - Settings and configuration management""" + +from debugai.config.settings import Settings + +__all__ = ["Settings"] diff --git a/src/debugai/config/settings.py b/src/debugai/config/settings.py new file mode 100644 index 0000000..3931a5b --- /dev/null +++ b/src/debugai/config/settings.py @@ -0,0 +1,266 @@ +""" +Settings - Configuration management for DebugAI +""" + +from typing import Any, Optional, Dict, List +from pathlib import Path +import os +import yaml + + +class Settings: + """ + Manages DebugAI configuration from multiple sources: + - Environment variables + - Config file (.debugai/config.yaml) + - User home directory (~/.debugai/config.yaml) + - Default values + """ + + # Configuration key mappings + KEY_MAPPINGS = { + "api-key": ("ai", "api_key"), + "model": ("ai", "model"), + "provider": ("ai", "provider"), + "format": ("output", "format"), + "theme": ("output", "theme"), + "correlation": ("analysis", "correlation"), + "max-errors": ("analysis", "max_errors"), + } + + # Environment variable mappings + ENV_MAPPINGS = { + "GEMINI_API_KEY": "api-key", + "DEBUGAI_MODEL": "model", + "DEBUGAI_FORMAT": "format", + } + + DEFAULTS = { + "ai": { + "provider": "gemini", + "model": "gemini-1.5-flash", + "api_key": None, + "max_tokens": 4096, + "temperature": 0.3, + }, + "parsing": { + "format": "auto", + "timestamp_formats": [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + ], + }, + "analysis": { + "correlation": True, + "max_errors": 100, + "correlation_window": 60, + "pattern_detection": True, + }, + "output": { + "format": "rich", + "theme": "auto", + "timestamps": True, + }, + "storage": { + "database": ".debugai/debugai.db", + "cache_ttl": 24, + "max_cache_size": 100, + }, + } + + def __init__(self): + self._config: Dict[str, Any] = {} + self._config_path: Optional[Path] = None + self._load_config() + + def _load_config(self) -> None: + """Load configuration from all sources.""" + # Start with defaults + self._config = self._deep_copy(self.DEFAULTS) + + # Load from home directory + home_config = Path.home() / ".debugai" / "config.yaml" + if home_config.exists(): + self._merge_config(self._load_yaml(home_config)) + + # Load from project directory + project_config = Path.cwd() / ".debugai" / "config.yaml" + if project_config.exists(): + self._config_path = project_config + self._merge_config(self._load_yaml(project_config)) + + # Load .env file (before env vars so explicit env vars take precedence) + self._load_dotenv() + + # Override with environment variables + self._load_env_vars() + + def _load_dotenv(self) -> None: + """Load environment variables from .env file if it exists.""" + # Check current directory first, then parent directories + env_paths = [ + Path.cwd() / ".env", + Path.cwd().parent / ".env", + ] + + for env_path in env_paths: + if env_path.exists(): + try: + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + # Parse KEY=value format + if "=" in line: + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + # Remove quotes if present + if value and value[0] in ('"', "'") and value[-1] == value[0]: + value = value[1:-1] + # Only set if not already in environment + if key and key not in os.environ: + os.environ[key] = value + break # Only load first .env found + except Exception: + pass # Silently ignore errors reading .env + + def _load_yaml(self, path: Path) -> Dict[str, Any]: + """Load YAML config file.""" + try: + with open(path, "r") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + + def _load_env_vars(self) -> None: + """Load configuration from environment variables.""" + for env_var, key in self.ENV_MAPPINGS.items(): + value = os.environ.get(env_var) + if value: + self.set(key, value, save=False) + + def _merge_config(self, new_config: Dict[str, Any]) -> None: + """Merge new configuration into existing.""" + for key, value in new_config.items(): + if key in self._config and isinstance(self._config[key], dict) and isinstance(value, dict): + self._config[key].update(value) + else: + self._config[key] = value + + def _deep_copy(self, obj: Any) -> Any: + """Deep copy a dictionary.""" + if isinstance(obj, dict): + return {k: self._deep_copy(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._deep_copy(item) for item in obj] + return obj + + def get(self, key: str, default: Any = None) -> Any: + """ + Get a configuration value. + + Args: + key: Configuration key (e.g., "api-key", "model") + default: Default value if not found + + Returns: + Configuration value + """ + # Check if it's a mapped key + if key in self.KEY_MAPPINGS: + section, subkey = self.KEY_MAPPINGS[key] + return self._config.get(section, {}).get(subkey, default) + + # Check if it's a dotted path (e.g., "ai.model") + if "." in key: + parts = key.split(".") + value = self._config + for part in parts: + if isinstance(value, dict): + value = value.get(part) + else: + return default + return value if value is not None else default + + # Check top-level + return self._config.get(key, default) + + def set(self, key: str, value: Any, save: bool = True) -> None: + """ + Set a configuration value. + + Args: + key: Configuration key + value: Value to set + save: Whether to save to config file + """ + # Check if it's a mapped key + if key in self.KEY_MAPPINGS: + section, subkey = self.KEY_MAPPINGS[key] + if section not in self._config: + self._config[section] = {} + self._config[section][subkey] = value + elif "." in key: + parts = key.split(".") + config = self._config + for part in parts[:-1]: + if part not in config: + config[part] = {} + config = config[part] + config[parts[-1]] = value + else: + self._config[key] = value + + if save: + self._save_config() + + def list_all(self) -> List[Dict[str, Any]]: + """List all configuration values.""" + items = [] + + # Add mapped keys + for key, (section, subkey) in self.KEY_MAPPINGS.items(): + value = self._config.get(section, {}).get(subkey) + source = "config" if self._config_path else "default" + + # Check if from environment + for env_var, mapped_key in self.ENV_MAPPINGS.items(): + if mapped_key == key and os.environ.get(env_var): + source = "environment" + break + + items.append({ + "key": key, + "value": value, + "source": source + }) + + return items + + def reset(self) -> None: + """Reset configuration to defaults.""" + self._config = self._deep_copy(self.DEFAULTS) + self._save_config() + + def _save_config(self) -> None: + """Save configuration to file.""" + if self._config_path is None: + self._config_path = Path.cwd() / ".debugai" / "config.yaml" + self._config_path.parent.mkdir(exist_ok=True) + + # Don't save API keys to file + save_config = self._deep_copy(self._config) + if "ai" in save_config and "api_key" in save_config["ai"]: + if save_config["ai"]["api_key"]: + save_config["ai"]["api_key"] = "***" # Mask + + with open(self._config_path, "w") as f: + yaml.dump(save_config, f, default_flow_style=False) + + @property + def config_path(self) -> Optional[Path]: + """Get current config file path.""" + return self._config_path diff --git a/src/debugai/core/__init__.py b/src/debugai/core/__init__.py new file mode 100644 index 0000000..2343790 --- /dev/null +++ b/src/debugai/core/__init__.py @@ -0,0 +1,6 @@ +"""Core Package""" + +from debugai.core.engine import DebugEngine +from debugai.core.analyzer import LogAnalyzer + +__all__ = ["DebugEngine", "LogAnalyzer"] diff --git a/src/debugai/core/analyzer.py b/src/debugai/core/analyzer.py new file mode 100644 index 0000000..8dd661d --- /dev/null +++ b/src/debugai/core/analyzer.py @@ -0,0 +1,182 @@ +""" +Log Analyzer - Pattern detection and analysis +""" + +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +import re +from collections import defaultdict + + +@dataclass +class Pattern: + """Represents a detected log pattern.""" + template: str + count: int + level: str + examples: List[str] + services: List[str] + + +class LogAnalyzer: + """ + Analyzes logs to detect patterns, anomalies, and correlations. + """ + + # Common error patterns + ERROR_PATTERNS = [ + (r"(?i)exception|error|failed|failure", "error"), + (r"(?i)warning|warn", "warning"), + (r"(?i)timeout|timed out", "timeout"), + (r"(?i)connection refused|connection reset", "connection"), + (r"(?i)out of memory|oom|memory", "memory"), + (r"(?i)permission denied|access denied|unauthorized", "permission"), + (r"(?i)not found|404|missing", "not_found"), + (r"(?i)null pointer|nullptr|nil|undefined", "null_reference"), + ] + + def __init__(self): + self._patterns: Dict[str, Pattern] = {} + self._error_clusters: List[Dict[str, Any]] = [] + + def analyze(self, logs: List[Dict[str, Any]]) -> Dict[str, Any]: + """Perform comprehensive analysis on logs.""" + return { + "patterns": self.detect_patterns(logs), + "error_types": self.categorize_errors(logs), + "anomalies": self.detect_anomalies(logs), + "hot_spots": self.find_hot_spots(logs), + "statistics": self.calculate_statistics(logs), + } + + def detect_patterns(self, logs: List[Dict[str, Any]]) -> List[Pattern]: + """Detect recurring patterns in logs.""" + pattern_counts = defaultdict(lambda: {"count": 0, "examples": [], "services": set()}) + + for log in logs: + message = log.get("message", "") + # Normalize message (replace numbers, IDs, etc.) + template = self._normalize_message(message) + + pattern_counts[template]["count"] += 1 + if len(pattern_counts[template]["examples"]) < 3: + pattern_counts[template]["examples"].append(message) + pattern_counts[template]["services"].add(log.get("service", "unknown")) + + # Convert to Pattern objects + patterns = [] + for template, data in pattern_counts.items(): + if data["count"] > 1: # Only patterns that occur more than once + patterns.append(Pattern( + template=template, + count=data["count"], + level=self._detect_level(template), + examples=data["examples"], + services=list(data["services"]) + )) + + # Sort by count + patterns.sort(key=lambda p: p.count, reverse=True) + return patterns[:50] # Top 50 patterns + + def categorize_errors(self, logs: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: + """Categorize errors by type.""" + categories = defaultdict(list) + + for log in logs: + if log.get("level") in ("error", "critical", "fatal"): + message = log.get("message", "") + category = self._categorize_error(message) + categories[category].append(log) + + return dict(categories) + + def detect_anomalies(self, logs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Detect anomalies in log patterns.""" + anomalies = [] + + # Detect sudden spikes in error rate + error_counts = defaultdict(int) + for log in logs: + if log.get("timestamp"): + minute = log["timestamp"][:16] # Group by minute + if log.get("level") in ("error", "critical"): + error_counts[minute] += 1 + + if error_counts: + avg = sum(error_counts.values()) / len(error_counts) + for minute, count in error_counts.items(): + if count > avg * 3: # 3x average is anomaly + anomalies.append({ + "type": "error_spike", + "time": minute, + "count": count, + "average": avg, + "severity": "high" + }) + + return anomalies + + def find_hot_spots(self, logs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Find services or components with most errors.""" + service_errors = defaultdict(int) + + for log in logs: + if log.get("level") in ("error", "critical"): + service = log.get("service", "unknown") + service_errors[service] += 1 + + hot_spots = [ + {"service": service, "error_count": count} + for service, count in sorted(service_errors.items(), key=lambda x: x[1], reverse=True) + ] + + return hot_spots[:10] + + def calculate_statistics(self, logs: List[Dict[str, Any]]) -> Dict[str, Any]: + """Calculate log statistics.""" + level_counts = defaultdict(int) + service_counts = defaultdict(int) + + for log in logs: + level_counts[log.get("level", "unknown")] += 1 + service_counts[log.get("service", "unknown")] += 1 + + return { + "total": len(logs), + "by_level": dict(level_counts), + "by_service": dict(service_counts), + "error_rate": level_counts.get("error", 0) / max(len(logs), 1) * 100, + } + + def _normalize_message(self, message: str) -> str: + """Normalize a message to create a template.""" + # Replace numbers + result = re.sub(r'\d+', '', message) + # Replace UUIDs + result = re.sub(r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', '', result) + # Replace hex strings + result = re.sub(r'0x[a-f0-9]+', '', result) + # Replace IP addresses + result = re.sub(r'\d+\.\d+\.\d+\.\d+', '', result) + # Replace quoted strings + result = re.sub(r'"[^"]*"', '', result) + result = re.sub(r"'[^']*'", '', result) + + return result + + def _detect_level(self, message: str) -> str: + """Detect log level from message content.""" + message_lower = message.lower() + if any(word in message_lower for word in ["error", "exception", "failed", "fatal"]): + return "error" + elif any(word in message_lower for word in ["warn", "warning"]): + return "warning" + return "info" + + def _categorize_error(self, message: str) -> str: + """Categorize an error message.""" + for pattern, category in self.ERROR_PATTERNS: + if re.search(pattern, message): + return category + return "other" diff --git a/src/debugai/core/doctor.py b/src/debugai/core/doctor.py new file mode 100644 index 0000000..1fd34dd --- /dev/null +++ b/src/debugai/core/doctor.py @@ -0,0 +1,160 @@ +""" +Doctor - Diagnose and fix common issues +""" + +from typing import List, Dict, Any +from pathlib import Path +import os +import sys + + +def run_diagnostics() -> List[Dict[str, Any]]: + """Run all diagnostic checks.""" + checks = [ + _check_python_version(), + _check_dependencies(), + _check_initialization(), + _check_api_key(), + _check_permissions(), + _check_disk_space(), + ] + return checks + + +def _check_python_version() -> Dict[str, Any]: + """Check Python version.""" + version = sys.version_info + if version >= (3, 11): + return { + "name": "Python Version", + "passed": True, + "details": f"Python {version.major}.{version.minor}.{version.micro}" + } + return { + "name": "Python Version", + "passed": False, + "fix": f"Upgrade to Python 3.11+ (current: {version.major}.{version.minor})" + } + + +def _check_dependencies() -> Dict[str, Any]: + """Check if required dependencies are installed.""" + required = { + "typer": "typer", + "rich": "rich", + "google-generativeai": "google.generativeai" + } + missing = [] + + for pkg_name, import_name in required.items(): + try: + __import__(import_name) + except ImportError: + missing.append(pkg_name) + + if not missing: + return { + "name": "Dependencies", + "passed": True, + "details": "All required packages installed" + } + return { + "name": "Dependencies", + "passed": False, + "fix": f"pip install {' '.join(missing)}" + } + + +def _check_initialization() -> Dict[str, Any]: + """Check if DebugAI is initialized.""" + debugai_dir = Path.cwd() / ".debugai" + + if debugai_dir.exists(): + return { + "name": "Initialization", + "passed": True, + "details": f"Initialized at {debugai_dir}" + } + return { + "name": "Initialization", + "passed": False, + "fix": "Run 'debugai init' to initialize" + } + + +def _check_api_key() -> Dict[str, Any]: + """Check if API key is configured.""" + if os.environ.get("GEMINI_API_KEY"): + return { + "name": "API Key", + "passed": True, + "details": "GEMINI_API_KEY environment variable set" + } + + # Check config + config_path = Path.cwd() / ".debugai" / "config.yaml" + if config_path.exists(): + content = config_path.read_text() + if "api_key:" in content and "YOUR_API_KEY" not in content: + return { + "name": "API Key", + "passed": True, + "details": "API key found in config" + } + + return { + "name": "API Key", + "passed": False, + "fix": "Set GEMINI_API_KEY environment variable or run 'debugai config set api-key YOUR_KEY'" + } + + +def _check_permissions() -> Dict[str, Any]: + """Check file permissions.""" + debugai_dir = Path.cwd() / ".debugai" + + try: + if debugai_dir.exists(): + # Try to write a test file + test_file = debugai_dir / ".permission_test" + test_file.write_text("test") + test_file.unlink() + + return { + "name": "Permissions", + "passed": True, + "details": "Read/write access OK" + } + except PermissionError: + return { + "name": "Permissions", + "passed": False, + "fix": f"Grant write permissions to {debugai_dir}" + } + + +def _check_disk_space() -> Dict[str, Any]: + """Check available disk space.""" + import shutil + + try: + total, used, free = shutil.disk_usage(Path.cwd()) + free_gb = free / (1024 ** 3) + + if free_gb > 1: + return { + "name": "Disk Space", + "passed": True, + "details": f"{free_gb:.1f} GB available" + } + return { + "name": "Disk Space", + "passed": False, + "fix": f"Free up disk space (only {free_gb:.2f} GB available)" + } + except: + return { + "name": "Disk Space", + "passed": True, + "details": "Could not check (assuming OK)" + } diff --git a/src/debugai/core/engine.py b/src/debugai/core/engine.py new file mode 100644 index 0000000..c9d446b --- /dev/null +++ b/src/debugai/core/engine.py @@ -0,0 +1,179 @@ +""" +Debug Engine - Core orchestration for log analysis + +This is the main engine that coordinates all analysis operations. +""" + +from typing import Optional, List, Dict, Any +from pathlib import Path +from dataclasses import dataclass, field +from datetime import datetime +import hashlib +import json + + +@dataclass +class LogEntry: + """Represents a single log entry.""" + raw: str + timestamp: Optional[datetime] = None + level: str = "info" + service: str = "unknown" + message: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + error_id: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "raw": self.raw, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + "level": self.level, + "service": self.service, + "message": self.message, + "metadata": self.metadata, + "error_id": self.error_id, + } + + +@dataclass +class AnalysisResult: + """Result of log analysis.""" + total_entries: int = 0 + errors: List[Dict[str, Any]] = field(default_factory=list) + warnings: List[Dict[str, Any]] = field(default_factory=list) + root_causes: List[Dict[str, Any]] = field(default_factory=list) + suggestions: List[Dict[str, Any]] = field(default_factory=list) + summary: str = "" + timeline: List[Dict[str, Any]] = field(default_factory=list) + + +class DebugEngine: + """ + Main debugging engine that orchestrates all analysis operations. + """ + + def __init__(self): + self._parsed_logs: List[LogEntry] = [] + self._errors: List[Dict[str, Any]] = [] + self._error_index: Dict[str, Dict[str, Any]] = {} + + def parse_logs(self, raw_logs: List[Dict[str, Any]]) -> List[Any]: + """Parse raw log entries into structured log objects.""" + from debugai.ingestion.parser import LogParser, ParsedLog + + parser = LogParser() + self._parsed_logs = [] + + for raw in raw_logs: + entry = parser.parse(raw) + if entry: + # Generate error ID for errors + if entry.level in ("error", "critical", "fatal"): + entry.error_id = self._generate_error_id(entry) + self._error_index[entry.error_id] = entry.to_dict() + + self._parsed_logs.append(entry) + + return self._parsed_logs + + def identify_errors(self, logs: List[Any]) -> List[Dict[str, Any]]: + """Identify and extract errors from parsed logs.""" + errors = [] + + for entry in logs: + level = entry.level if hasattr(entry, 'level') else entry.get('level', 'info') + if level in ("error", "critical", "fatal", "exception"): + if hasattr(entry, 'to_dict'): + error_dict = entry.to_dict() + else: + error_dict = dict(entry) + + error_id = getattr(entry, 'error_id', None) or self._generate_error_id(entry) + error_dict["error_id"] = error_id + errors.append(error_dict) + + # Store in index + self._error_index[error_dict["error_id"]] = error_dict + + self._errors = errors + return errors + + def ai_analyze( + self, + errors: List[Dict[str, Any]], + context: List[LogEntry], + max_errors: int = 50 + ) -> Dict[str, Any]: + """Run AI-powered analysis on errors.""" + from debugai.ai.gemini_client import GeminiClient + + ai = GeminiClient() + + # Prepare context + context_text = self._prepare_context(context, errors) + + # Get AI analysis + analysis = ai.analyze_errors( + errors=errors[:max_errors], + context=context_text + ) + + return analysis + + def get_error_by_id(self, error_id: str) -> Optional[Dict[str, Any]]: + """Retrieve an error by its ID.""" + # Check in-memory index first + if error_id in self._error_index: + return self._error_index[error_id] + + # Check database + from debugai.storage.database import Database + db = Database() + return db.get_error(error_id) + + def quick_analyze(self, path: str) -> Dict[str, Any]: + """Quick analysis of a log file or directory.""" + from debugai.ingestion.file_ingester import FileIngester + + ingester = FileIngester() + logs = ingester.ingest(Path(path)) + + parsed = self.parse_logs(logs) + errors = self.identify_errors(parsed) + + return { + "total_entries": len(parsed), + "error_count": len(errors), + "errors": errors[:10], # First 10 errors + } + + def _generate_error_id(self, entry: Any) -> str: + """Generate a unique ID for an error.""" + if hasattr(entry, 'service'): + service = entry.service + message = entry.message + level = entry.level + else: + service = entry.get('service', 'unknown') + message = entry.get('message', '') + level = entry.get('level', 'error') + + content = f"{service}:{message}:{level}" + return "err_" + hashlib.sha256(content.encode()).hexdigest()[:12] + + def _prepare_context( + self, + logs: List[Any], + errors: List[Dict[str, Any]] + ) -> str: + """Prepare context string for AI analysis.""" + lines = [] + + # Add recent log entries around errors + for error in errors[:10]: + lines.append(f"ERROR: {error.get('message', 'Unknown')}") + lines.append(f" Service: {error.get('service', 'Unknown')}") + lines.append(f" Time: {error.get('timestamp', 'Unknown')}") + lines.append("") + + return "\n".join(lines) diff --git a/src/debugai/core/initializer.py b/src/debugai/core/initializer.py new file mode 100644 index 0000000..d259691 --- /dev/null +++ b/src/debugai/core/initializer.py @@ -0,0 +1,126 @@ +""" +Project Initializer - Set up DebugAI in a directory +""" + +from pathlib import Path +from dataclasses import dataclass +from typing import Optional +import os + + +@dataclass +class InitResult: + """Result of initialization.""" + success: bool + message: str + path: Optional[Path] = None + + +def initialize_project(path: Optional[Path] = None) -> InitResult: + """ + Initialize DebugAI in the specified directory. + Creates .debugai folder with configuration and cache directories. + """ + base_path = path or Path.cwd() + debugai_dir = base_path / ".debugai" + + try: + # Create main directory + debugai_dir.mkdir(exist_ok=True) + + # Create subdirectories + (debugai_dir / "cache").mkdir(exist_ok=True) + (debugai_dir / "patterns").mkdir(exist_ok=True) + (debugai_dir / "reports").mkdir(exist_ok=True) + + # Create default config + config_path = debugai_dir / "config.yaml" + if not config_path.exists(): + config_path.write_text(DEFAULT_CONFIG) + + # Create .gitignore + gitignore_path = debugai_dir / ".gitignore" + if not gitignore_path.exists(): + gitignore_path.write_text("cache/\n*.db\n*.log\n") + + return InitResult( + success=True, + message="DebugAI initialized successfully", + path=debugai_dir + ) + + except Exception as e: + return InitResult( + success=False, + message=str(e) + ) + + +DEFAULT_CONFIG = """# DebugAI Configuration +# https://debugai.dev/docs/configuration + +# AI Settings +ai: + provider: gemini + model: gemini-1.5-flash + # api_key: YOUR_API_KEY # Or set GEMINI_API_KEY env var + max_tokens: 4096 + temperature: 0.3 + +# Log Parsing +parsing: + # Supported formats: auto, json, apache, nginx, syslog, custom + format: auto + + # Custom timestamp formats + timestamp_formats: + - "%Y-%m-%d %H:%M:%S" + - "%Y-%m-%dT%H:%M:%S" + - "%d/%b/%Y:%H:%M:%S" + + # Log level mapping + level_mapping: + error: [error, err, fatal, critical, crit] + warning: [warning, warn, wrn] + info: [info, inf, notice] + debug: [debug, dbg, trace] + +# Analysis Settings +analysis: + # Enable cross-service correlation + correlation: true + + # Maximum errors to analyze in detail + max_errors: 100 + + # Time window for correlation (seconds) + correlation_window: 60 + + # Enable pattern detection + pattern_detection: true + +# Output Settings +output: + # Default format: rich, json, markdown, plain + format: rich + + # Color theme: dark, light, auto + theme: auto + + # Show timestamps + timestamps: true + +# Storage Settings +storage: + # Database location + database: .debugai/debugai.db + + # Cache TTL in hours + cache_ttl: 24 + + # Max cache size in MB + max_cache_size: 100 + +# Sources +sources: [] +""" diff --git a/src/debugai/core/status.py b/src/debugai/core/status.py new file mode 100644 index 0000000..4c0ee75 --- /dev/null +++ b/src/debugai/core/status.py @@ -0,0 +1,115 @@ +""" +Status - Check DebugAI status and health +""" + +from typing import Dict, Any +from pathlib import Path +import os + + +def get_status() -> Dict[str, Dict[str, Any]]: + """Get status of all DebugAI components.""" + status = {} + + # Check configuration + status["Configuration"] = _check_configuration() + + # Check API key + status["Gemini API"] = _check_api_key() + + # Check database + status["Database"] = _check_database() + + # Check log sources + status["Log Sources"] = _check_log_sources() + + return status + + +def _check_configuration() -> Dict[str, Any]: + """Check if DebugAI is configured.""" + config_path = Path.cwd() / ".debugai" / "config.yaml" + + if config_path.exists(): + return { + "healthy": True, + "status": "Configured", + "details": str(config_path) + } + return { + "healthy": False, + "status": "Not initialized", + "details": "Run 'debugai init' to initialize" + } + + +def _check_api_key() -> Dict[str, Any]: + """Check if Gemini API key is configured.""" + api_key = os.environ.get("GEMINI_API_KEY") + + if api_key: + return { + "healthy": True, + "status": "Configured", + "details": f"Key: {api_key[:8]}..." + } + + # Check config file + from debugai.config.settings import Settings + try: + settings = Settings() + if settings.get("api-key"): + return { + "healthy": True, + "status": "Configured", + "details": "From config file" + } + except: + pass + + return { + "healthy": False, + "status": "Not configured", + "details": "Set GEMINI_API_KEY or run 'debugai config set api-key YOUR_KEY'" + } + + +def _check_database() -> Dict[str, Any]: + """Check database status.""" + db_path = Path.cwd() / ".debugai" / "debugai.db" + + if db_path.exists(): + size_mb = db_path.stat().st_size / (1024 * 1024) + return { + "healthy": True, + "status": "Connected", + "details": f"Size: {size_mb:.2f} MB" + } + return { + "healthy": True, + "status": "Ready", + "details": "Will be created on first use" + } + + +def _check_log_sources() -> Dict[str, Any]: + """Check configured log sources.""" + try: + from debugai.storage.database import Database + db = Database() + sources = db.get_log_sources() + + if sources: + return { + "healthy": True, + "status": f"{len(sources)} configured", + "details": ", ".join(s["name"] for s in sources[:3]) + } + except: + pass + + return { + "healthy": True, + "status": "None configured", + "details": "Use 'debugai logs add' to add sources" + } diff --git a/src/debugai/ingestion/__init__.py b/src/debugai/ingestion/__init__.py new file mode 100644 index 0000000..553701c --- /dev/null +++ b/src/debugai/ingestion/__init__.py @@ -0,0 +1,8 @@ +"""Ingestion Package - Log ingestion from various sources""" + +from debugai.ingestion.file_ingester import FileIngester +from debugai.ingestion.docker_ingester import DockerIngester +from debugai.ingestion.stream_ingester import StreamIngester +from debugai.ingestion.parser import LogParser + +__all__ = ["FileIngester", "DockerIngester", "StreamIngester", "LogParser"] diff --git a/src/debugai/ingestion/docker_ingester.py b/src/debugai/ingestion/docker_ingester.py new file mode 100644 index 0000000..9471a07 --- /dev/null +++ b/src/debugai/ingestion/docker_ingester.py @@ -0,0 +1,196 @@ +""" +Docker Ingester - Ingest logs from Docker containers +""" + +from typing import List, Dict, Any, Optional, Generator +from datetime import datetime + + +class DockerIngester: + """ + Ingests logs from Docker containers. + Supports fetching logs from running and stopped containers. + """ + + def __init__(self): + self._client = None + self._connected = False + + def _get_client(self): + """Get or create Docker client.""" + if self._client is None: + try: + import docker + self._client = docker.from_env() + self._connected = True + except Exception as e: + raise RuntimeError(f"Failed to connect to Docker: {e}") + return self._client + + def ingest( + self, + container: str, + tail: int = 1000, + since: Optional[str] = None, + until: Optional[str] = None, + follow: bool = False, + ) -> List[Dict[str, Any]]: + """ + Ingest logs from a Docker container. + + Args: + container: Container name or ID + tail: Number of lines from end + since: Start time (Unix timestamp or datetime) + until: End time + follow: Follow log output + + Returns: + List of log entries + """ + client = self._get_client() + logs = [] + + try: + container_obj = client.containers.get(container) + + # Get container info + info = container_obj.attrs + service_name = info.get("Name", container).lstrip("/") + image = info.get("Config", {}).get("Image", "unknown") + + # Fetch logs + log_output = container_obj.logs( + tail=tail, + timestamps=True, + since=since, + until=until, + stream=False, + ) + + # Parse logs + for line in log_output.decode("utf-8", errors="replace").split("\n"): + if not line.strip(): + continue + + entry = self._parse_docker_log(line, service_name, image) + logs.append(entry) + + except Exception as e: + logs.append({ + "raw": f"Error fetching logs from {container}: {e}", + "source": f"docker:{container}", + "error": True, + "level": "error", + }) + + return logs + + def ingest_multiple( + self, + containers: List[str], + tail: int = 1000, + since: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Ingest logs from multiple containers.""" + all_logs = [] + + for container in containers: + logs = self.ingest(container, tail=tail, since=since) + all_logs.extend(logs) + + # Sort by timestamp + all_logs.sort(key=lambda x: x.get("timestamp", "")) + + return all_logs + + def stream( + self, + container: str, + since: Optional[str] = None, + ) -> Generator[Dict[str, Any], None, None]: + """Stream logs from a container in real-time.""" + client = self._get_client() + + try: + container_obj = client.containers.get(container) + service_name = container_obj.attrs.get("Name", container).lstrip("/") + image = container_obj.attrs.get("Config", {}).get("Image", "unknown") + + for line in container_obj.logs( + stream=True, + follow=True, + timestamps=True, + since=since, + ): + text = line.decode("utf-8", errors="replace").strip() + if text: + yield self._parse_docker_log(text, service_name, image) + + except Exception as e: + yield { + "raw": f"Stream error: {e}", + "source": f"docker:{container}", + "error": True, + "level": "error", + } + + def list_containers(self, all: bool = False) -> List[Dict[str, Any]]: + """List available containers.""" + client = self._get_client() + containers = [] + + for container in client.containers.list(all=all): + containers.append({ + "id": container.short_id, + "name": container.name, + "status": container.status, + "image": container.image.tags[0] if container.image.tags else "unknown", + }) + + return containers + + def _parse_docker_log( + self, + line: str, + service_name: str, + image: str + ) -> Dict[str, Any]: + """Parse a Docker log line.""" + entry = { + "raw": line, + "source": f"docker:{service_name}", + "service": service_name, + "image": image, + } + + # Docker logs with timestamps: "2024-01-01T00:00:00.000000000Z message" + if line and len(line) > 30 and line[4] == "-": + try: + timestamp_str = line[:30].strip() + message = line[31:].strip() + entry["timestamp"] = timestamp_str + entry["message"] = message + entry["raw"] = message + except: + entry["message"] = line + else: + entry["message"] = line + + # Detect level + entry["level"] = self._detect_level(entry.get("message", line)) + + return entry + + def _detect_level(self, message: str) -> str: + """Detect log level from message.""" + msg_upper = message.upper() + + if any(word in msg_upper for word in ["ERROR", "FATAL", "CRITICAL", "EXCEPTION"]): + return "error" + elif any(word in msg_upper for word in ["WARN", "WARNING"]): + return "warn" + elif any(word in msg_upper for word in ["DEBUG", "TRACE"]): + return "debug" + + return "info" diff --git a/src/debugai/ingestion/file_ingester.py b/src/debugai/ingestion/file_ingester.py new file mode 100644 index 0000000..f9722bf --- /dev/null +++ b/src/debugai/ingestion/file_ingester.py @@ -0,0 +1,240 @@ +""" +File Ingester - Ingest logs from files and directories +""" + +from pathlib import Path +from typing import List, Dict, Any, Optional, Generator +import os +import gzip +import re +from datetime import datetime + + +class FileIngester: + """ + Ingests log files from the filesystem. + Supports plain text, gzipped files, and recursive directory scanning. + """ + + SUPPORTED_EXTENSIONS = {".log", ".txt", ".json", ".gz"} + + def __init__(self): + self._file_count = 0 + self._line_count = 0 + + def ingest( + self, + path: Path, + services: Optional[List[str]] = None, + levels: Optional[List[str]] = None, + since: Optional[str] = None, + until: Optional[str] = None, + pattern: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + Ingest logs from a file or directory. + + Args: + path: Path to file or directory + services: Filter by service names + levels: Filter by log levels + since: Start time filter + until: End time filter + pattern: Regex pattern to match + + Returns: + List of raw log entries + """ + logs = [] + + if path.is_file(): + logs.extend(self._read_file(path)) + elif path.is_dir(): + logs.extend(self._read_directory(path)) + else: + raise FileNotFoundError(f"Path not found: {path}") + + # Apply filters + if services or levels or since or until or pattern: + logs = self._filter_logs(logs, services, levels, since, until, pattern) + + return logs + + def ingest_streaming( + self, + path: Path, + batch_size: int = 100 + ) -> Generator[List[Dict[str, Any]], None, None]: + """Ingest logs in batches for memory efficiency.""" + batch = [] + + for log in self._iter_file(path): + batch.append(log) + if len(batch) >= batch_size: + yield batch + batch = [] + + if batch: + yield batch + + def _read_file(self, path: Path) -> List[Dict[str, Any]]: + """Read a single log file.""" + logs = [] + + try: + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8", errors="replace") as f: + logs = self._parse_file_content(f, path) + else: + with open(path, "r", encoding="utf-8", errors="replace") as f: + logs = self._parse_file_content(f, path) + + self._file_count += 1 + except Exception as e: + # Log error but continue + logs.append({ + "raw": f"Error reading {path}: {e}", + "source": str(path), + "error": True + }) + + return logs + + def _read_directory(self, path: Path) -> List[Dict[str, Any]]: + """Recursively read all log files in a directory.""" + logs = [] + + for file_path in path.rglob("*"): + if file_path.is_file() and file_path.suffix in self.SUPPORTED_EXTENSIONS: + logs.extend(self._read_file(file_path)) + + return logs + + def _iter_file(self, path: Path) -> Generator[Dict[str, Any], None, None]: + """Iterate over lines in a file.""" + try: + opener = gzip.open if path.suffix == ".gz" else open + with opener(path, "rt", encoding="utf-8", errors="replace") as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if line: + yield { + "raw": line, + "source": str(path), + "line_number": line_num, + } + except Exception as e: + yield {"raw": f"Error: {e}", "source": str(path), "error": True} + + def _parse_file_content(self, file_handle, path: Path) -> List[Dict[str, Any]]: + """Parse content from a file handle.""" + logs = [] + service_name = self._detect_service_from_path(path) + + # Check if it's JSON logs (one JSON per line) + first_line = file_handle.readline() + file_handle.seek(0) + + is_json = first_line.strip().startswith("{") + + for line_num, line in enumerate(file_handle, 1): + line = line.strip() + if not line: + continue + + self._line_count += 1 + + entry = { + "raw": line, + "source": str(path), + "line_number": line_num, + "service": service_name, + } + + # Try to extract timestamp + timestamp = self._extract_timestamp(line) + if timestamp: + entry["timestamp"] = timestamp + + # Try to extract level + level = self._extract_level(line) + if level: + entry["level"] = level + + logs.append(entry) + + return logs + + def _detect_service_from_path(self, path: Path) -> str: + """Detect service name from file path.""" + # Try to extract service from filename like "api.log", "db-service.log" + name = path.stem + # Remove common suffixes + for suffix in [".log", "-log", "_log", ".error", ".access"]: + name = name.replace(suffix, "") + return name or "unknown" + + def _extract_timestamp(self, line: str) -> Optional[str]: + """Extract timestamp from log line.""" + patterns = [ + r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}", # ISO format + r"\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}", # Apache format + r"\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}", # Syslog format + ] + + for pattern in patterns: + match = re.search(pattern, line) + if match: + return match.group() + + return None + + def _extract_level(self, line: str) -> Optional[str]: + """Extract log level from log line.""" + line_upper = line.upper() + + if any(word in line_upper for word in ["ERROR", "ERR", "FATAL", "CRITICAL"]): + return "error" + elif any(word in line_upper for word in ["WARN", "WARNING"]): + return "warn" + elif any(word in line_upper for word in ["INFO", "NOTICE"]): + return "info" + elif any(word in line_upper for word in ["DEBUG", "TRACE"]): + return "debug" + + return None + + def _filter_logs( + self, + logs: List[Dict[str, Any]], + services: Optional[List[str]], + levels: Optional[List[str]], + since: Optional[str], + until: Optional[str], + pattern: Optional[str], + ) -> List[Dict[str, Any]]: + """Filter logs based on criteria.""" + filtered = logs + + if services: + filtered = [l for l in filtered if l.get("service") in services] + + if levels: + filtered = [l for l in filtered if l.get("level") in levels] + + if pattern: + regex = re.compile(pattern, re.IGNORECASE) + filtered = [l for l in filtered if regex.search(l.get("raw", ""))] + + # Time filtering would require parsing timestamps + # Simplified for now + + return filtered + + @property + def stats(self) -> Dict[str, int]: + """Get ingestion statistics.""" + return { + "files_processed": self._file_count, + "lines_processed": self._line_count, + } diff --git a/src/debugai/ingestion/parser.py b/src/debugai/ingestion/parser.py new file mode 100644 index 0000000..3f018d2 --- /dev/null +++ b/src/debugai/ingestion/parser.py @@ -0,0 +1,214 @@ +""" +Log Parser - Parse and structure log entries +""" + +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +from datetime import datetime +import re +import json + + +@dataclass +class ParsedLog: + """Structured log entry.""" + raw: str + timestamp: Optional[datetime] + level: str + service: str + message: str + metadata: Dict[str, Any] + error_id: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "raw": self.raw, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + "level": self.level, + "service": self.service, + "message": self.message, + "metadata": self.metadata, + "error_id": self.error_id, + } + + def get(self, key: str, default: Any = None) -> Any: + """Get attribute by key (dict-like access).""" + return getattr(self, key, default) + + +class LogParser: + """ + Parses raw log entries into structured format. + Supports multiple log formats: JSON, Apache, Nginx, Syslog, and custom patterns. + """ + + # Common timestamp patterns + TIMESTAMP_PATTERNS = [ + (r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)", "%Y-%m-%dT%H:%M:%S"), + (r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:,\d+)?)", "%Y-%m-%d %H:%M:%S"), + (r"(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})", "%d/%b/%Y:%H:%M:%S"), + (r"(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})", None), # Syslog + ] + + # Log level patterns + LEVEL_PATTERNS = [ + (r"\b(FATAL|CRITICAL)\b", "critical"), + (r"\b(ERROR|ERR)\b", "error"), + (r"\b(WARN|WARNING)\b", "warn"), + (r"\b(INFO|NOTICE)\b", "info"), + (r"\b(DEBUG|TRACE)\b", "debug"), + ] + + def __init__(self): + self._custom_patterns: List[tuple] = [] + + def parse(self, entry: Dict[str, Any]) -> Optional["ParsedLog"]: + """Parse a raw log entry.""" + raw = entry.get("raw", "") + + if not raw: + return None + + # Try JSON first + if raw.strip().startswith("{"): + return self._parse_json(raw, entry) + + # Parse as text + return self._parse_text(raw, entry) + + def _parse_json(self, raw: str, entry: Dict[str, Any]) -> Optional[ParsedLog]: + """Parse JSON log entry.""" + try: + data = json.loads(raw) + + # Extract common fields + timestamp = self._extract_timestamp_from_json(data) + level = self._extract_level_from_json(data) + message = self._extract_message_from_json(data) + service = data.get("service") or data.get("app") or entry.get("service", "unknown") + + # Everything else is metadata + metadata = {k: v for k, v in data.items() + if k not in ["timestamp", "time", "@timestamp", "level", "severity", + "message", "msg", "service", "app"]} + + return ParsedLog( + raw=raw, + timestamp=timestamp, + level=level, + service=service, + message=message, + metadata=metadata + ) + except json.JSONDecodeError: + return self._parse_text(raw, entry) + + def _parse_text(self, raw: str, entry: Dict[str, Any]) -> ParsedLog: + """Parse text log entry.""" + timestamp = self._extract_timestamp(raw) or entry.get("timestamp") + level = self._extract_level(raw) or entry.get("level", "info") + service = entry.get("service", "unknown") + message = self._extract_message(raw) + + # Try to extract additional metadata + metadata = {} + + # Extract trace IDs + trace_match = re.search(r"trace[_-]?id[=:]\s*([a-f0-9-]+)", raw, re.I) + if trace_match: + metadata["trace_id"] = trace_match.group(1) + + # Extract request IDs + req_match = re.search(r"request[_-]?id[=:]\s*([a-f0-9-]+)", raw, re.I) + if req_match: + metadata["request_id"] = req_match.group(1) + + return ParsedLog( + raw=raw, + timestamp=timestamp, + level=level, + service=service, + message=message, + metadata=metadata + ) + + def _extract_timestamp(self, text: str) -> Optional[datetime]: + """Extract timestamp from text.""" + for pattern, fmt in self.TIMESTAMP_PATTERNS: + match = re.search(pattern, text) + if match: + ts_str = match.group(1) + try: + if fmt: + # Handle various formats + ts_str = ts_str.replace(",", ".") + ts_str = re.sub(r"\.\d+", "", ts_str) # Remove microseconds + ts_str = re.sub(r"Z$", "", ts_str) # Remove Z + ts_str = re.sub(r"[+-]\d{2}:\d{2}$", "", ts_str) # Remove timezone + return datetime.strptime(ts_str[:19], fmt[:len(ts_str)]) + except: + pass + return None + + def _extract_timestamp_from_json(self, data: Dict) -> Optional[datetime]: + """Extract timestamp from JSON data.""" + for key in ["timestamp", "time", "@timestamp", "ts", "datetime"]: + if key in data: + value = data[key] + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value) + elif isinstance(value, str): + return self._extract_timestamp(value) + return None + + def _extract_level(self, text: str) -> str: + """Extract log level from text.""" + for pattern, level in self.LEVEL_PATTERNS: + if re.search(pattern, text, re.I): + return level + return "info" + + def _extract_level_from_json(self, data: Dict) -> str: + """Extract log level from JSON data.""" + for key in ["level", "severity", "loglevel", "log_level"]: + if key in data: + value = str(data[key]).lower() + if value in ["fatal", "critical", "crit"]: + return "critical" + elif value in ["error", "err"]: + return "error" + elif value in ["warn", "warning"]: + return "warn" + elif value in ["info", "notice"]: + return "info" + elif value in ["debug", "trace"]: + return "debug" + return "info" + + def _extract_message(self, text: str) -> str: + """Extract the main message from log text.""" + # Remove timestamp + for pattern, _ in self.TIMESTAMP_PATTERNS: + text = re.sub(pattern, "", text) + + # Remove level + for pattern, _ in self.LEVEL_PATTERNS: + text = re.sub(pattern, "", text, flags=re.I) + + # Clean up + text = re.sub(r"^\s*[-:\[\]]+\s*", "", text) + text = text.strip() + + return text or "No message" + + def _extract_message_from_json(self, data: Dict) -> str: + """Extract message from JSON data.""" + for key in ["message", "msg", "text", "log", "error"]: + if key in data: + return str(data[key]) + return str(data) + + def add_custom_pattern(self, name: str, pattern: str, fields: List[str]) -> None: + """Add a custom parsing pattern.""" + self._custom_patterns.append((name, re.compile(pattern), fields)) diff --git a/src/debugai/ingestion/stream_ingester.py b/src/debugai/ingestion/stream_ingester.py new file mode 100644 index 0000000..21a3a10 --- /dev/null +++ b/src/debugai/ingestion/stream_ingester.py @@ -0,0 +1,218 @@ +""" +Stream Ingester - Ingest logs from streams (stdin, files, URLs) +""" + +from typing import List, Dict, Any, Optional, Generator +import sys +import time + + +class StreamIngester: + """ + Ingests logs from streaming sources. + Supports stdin, file tailing, and HTTP streams. + """ + + def __init__(self): + self._buffer = [] + + def stream( + self, + source: str, + buffer_size: int = 100, + ) -> Generator[List[Dict[str, Any]], None, None]: + """ + Stream logs from a source. + + Args: + source: Source type ("stdin", file path, or URL) + buffer_size: Lines to buffer before yielding + + Yields: + Batches of log entries + """ + if source == "stdin": + yield from self._stream_stdin(buffer_size) + elif source.startswith("http://") or source.startswith("https://"): + yield from self._stream_http(source, buffer_size) + else: + yield from self._stream_file(source, buffer_size) + + def _stream_stdin( + self, + buffer_size: int + ) -> Generator[List[Dict[str, Any]], None, None]: + """Stream from stdin.""" + buffer = [] + + try: + for line in sys.stdin: + line = line.strip() + if line: + buffer.append(self._parse_line(line, "stdin")) + + if len(buffer) >= buffer_size: + yield buffer + buffer = [] + except KeyboardInterrupt: + pass + + if buffer: + yield buffer + + def _stream_file( + self, + path: str, + buffer_size: int + ) -> Generator[List[Dict[str, Any]], None, None]: + """Stream from a file (tail -f style).""" + buffer = [] + + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + # Go to end of file + f.seek(0, 2) + + while True: + line = f.readline() + + if line: + line = line.strip() + if line: + buffer.append(self._parse_line(line, path)) + + if len(buffer) >= buffer_size: + yield buffer + buffer = [] + else: + if buffer: + yield buffer + buffer = [] + time.sleep(0.1) + except KeyboardInterrupt: + pass + + if buffer: + yield buffer + + def _stream_http( + self, + url: str, + buffer_size: int + ) -> Generator[List[Dict[str, Any]], None, None]: + """Stream from HTTP endpoint.""" + import httpx + + buffer = [] + + try: + with httpx.stream("GET", url) as response: + for line in response.iter_lines(): + if line: + buffer.append(self._parse_line(line, url)) + + if len(buffer) >= buffer_size: + yield buffer + buffer = [] + except Exception as e: + buffer.append({ + "raw": f"Stream error: {e}", + "source": url, + "level": "error", + "error": True, + }) + + if buffer: + yield buffer + + def watch( + self, + source_name: Optional[str] = None + ) -> Generator[Dict[str, Any], None, None]: + """Watch configured log sources for new entries.""" + from debugai.storage.database import Database + + db = Database() + sources = db.get_log_sources() + + if source_name: + sources = [s for s in sources if s["name"] == source_name] + + if not sources: + return + + # Use watchdog for file monitoring + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + class LogHandler(FileSystemEventHandler): + def __init__(self, callback): + self.callback = callback + self._file_positions = {} + + def on_modified(self, event): + if event.is_directory: + return + + path = event.src_path + pos = self._file_positions.get(path, 0) + + try: + with open(path, "r") as f: + f.seek(pos) + for line in f: + self.callback(line.strip(), path) + self._file_positions[path] = f.tell() + except: + pass + + entries = [] + + def callback(line, path): + entry = self._parse_line(line, path) + entries.append(entry) + + observer = Observer() + handler = LogHandler(callback) + + for source in sources: + observer.schedule(handler, source["path"], recursive=True) + + observer.start() + + try: + while True: + while entries: + yield entries.pop(0) + time.sleep(0.1) + except KeyboardInterrupt: + observer.stop() + + observer.join() + + except ImportError: + # Fallback without watchdog + while True: + time.sleep(1) + + def _parse_line(self, line: str, source: str) -> Dict[str, Any]: + """Parse a single log line.""" + entry = { + "raw": line, + "source": source, + "message": line, + } + + # Detect level + line_upper = line.upper() + if "ERROR" in line_upper or "FATAL" in line_upper: + entry["level"] = "error" + elif "WARN" in line_upper: + entry["level"] = "warn" + elif "DEBUG" in line_upper: + entry["level"] = "debug" + else: + entry["level"] = "info" + + return entry diff --git a/src/debugai/storage/__init__.py b/src/debugai/storage/__init__.py new file mode 100644 index 0000000..9a226b2 --- /dev/null +++ b/src/debugai/storage/__init__.py @@ -0,0 +1,5 @@ +"""Storage Package - Database and caching""" + +from debugai.storage.database import Database + +__all__ = ["Database"] diff --git a/src/debugai/storage/database.py b/src/debugai/storage/database.py new file mode 100644 index 0000000..21b3ec7 --- /dev/null +++ b/src/debugai/storage/database.py @@ -0,0 +1,355 @@ +""" +Database - SQLite storage for logs and analysis data +""" + +from typing import List, Dict, Any, Optional +from pathlib import Path +from datetime import datetime, timedelta +import sqlite3 +import json +import os + + +class Database: + """ + SQLite database for storing logs, errors, and analysis results. + """ + + def __init__(self, db_path: Optional[str] = None): + """ + Initialize database. + + Args: + db_path: Path to database file (default: .debugai/debugai.db) + """ + if db_path is None: + debugai_dir = Path.cwd() / ".debugai" + debugai_dir.mkdir(exist_ok=True) + db_path = str(debugai_dir / "debugai.db") + + self.db_path = db_path + self._connection = None + self._init_db() + + def _get_connection(self) -> sqlite3.Connection: + """Get or create database connection.""" + if self._connection is None: + self._connection = sqlite3.connect(self.db_path) + self._connection.row_factory = sqlite3.Row + return self._connection + + def _init_db(self) -> None: + """Initialize database schema.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Log sources table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS log_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + path TEXT NOT NULL, + pattern TEXT DEFAULT '*.log', + service TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Errors table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + error_id TEXT UNIQUE, + level TEXT, + service TEXT, + message TEXT, + raw TEXT, + timestamp TEXT, + source TEXT, + metadata TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Events table (for timeline) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT UNIQUE, + type TEXT, + level TEXT, + service TEXT, + message TEXT, + timestamp TEXT, + metadata TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Analysis cache table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS analysis_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_key TEXT UNIQUE, + result TEXT, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create indexes + cursor.execute("CREATE INDEX IF NOT EXISTS idx_errors_timestamp ON errors(timestamp)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_errors_service ON errors(service)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)") + + conn.commit() + + # Log Sources + def add_log_source( + self, + name: str, + path: str, + pattern: str = "*.log", + service: Optional[str] = None + ) -> None: + """Add a log source.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT OR REPLACE INTO log_sources (name, path, pattern, service) + VALUES (?, ?, ?, ?) + """, (name, path, pattern, service)) + + conn.commit() + + def get_log_sources(self) -> List[Dict[str, Any]]: + """Get all log sources.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM log_sources ORDER BY name") + rows = cursor.fetchall() + + return [dict(row) for row in rows] + + def remove_log_source(self, name: str) -> None: + """Remove a log source.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("DELETE FROM log_sources WHERE name = ?", (name,)) + conn.commit() + + # Errors + def save_error(self, error: Dict[str, Any]) -> None: + """Save an error to the database.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT OR REPLACE INTO errors + (error_id, level, service, message, raw, timestamp, source, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + error.get("error_id"), + error.get("level"), + error.get("service"), + error.get("message"), + error.get("raw"), + error.get("timestamp"), + error.get("source"), + json.dumps(error.get("metadata", {})) + )) + + conn.commit() + + def save_errors(self, errors: List[Dict[str, Any]]) -> None: + """Save multiple errors.""" + for error in errors: + self.save_error(error) + + def get_error(self, error_id: str) -> Optional[Dict[str, Any]]: + """Get an error by ID.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM errors WHERE error_id = ?", (error_id,)) + row = cursor.fetchone() + + if row: + result = dict(row) + result["metadata"] = json.loads(result.get("metadata", "{}")) + return result + + return None + + def get_recent_errors(self, limit: int = 100) -> List[Dict[str, Any]]: + """Get recent errors.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM errors + ORDER BY created_at DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + results = [] + for row in rows: + result = dict(row) + result["metadata"] = json.loads(result.get("metadata", "{}")) + results.append(result) + + return results + + # Events + def save_event(self, event: Dict[str, Any]) -> None: + """Save an event.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT OR REPLACE INTO events + (event_id, type, level, service, message, timestamp, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + event.get("event_id"), + event.get("type"), + event.get("level"), + event.get("service"), + event.get("message"), + event.get("timestamp"), + json.dumps(event.get("metadata", {})) + )) + + conn.commit() + + def get_events( + self, + since: Optional[datetime] = None, + level: Optional[str] = None, + service: Optional[str] = None, + limit: int = 100 + ) -> List[Dict[str, Any]]: + """Get events with filters.""" + conn = self._get_connection() + cursor = conn.cursor() + + query = "SELECT * FROM events WHERE 1=1" + params = [] + + if since: + query += " AND timestamp >= ?" + params.append(since.isoformat()) + + if level: + if level == "errors": + query += " AND level IN ('error', 'critical', 'fatal')" + elif level == "warnings": + query += " AND level IN ('warn', 'warning')" + + if service: + query += " AND service = ?" + params.append(service) + + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + rows = cursor.fetchall() + + return [dict(row) for row in rows] + + def get_events_before( + self, + timestamp: str, + delta: timedelta, + service: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Get events before a timestamp.""" + conn = self._get_connection() + cursor = conn.cursor() + + try: + end_time = datetime.fromisoformat(timestamp[:19]) + start_time = end_time - delta + except: + return [] + + query = "SELECT * FROM events WHERE timestamp >= ? AND timestamp <= ?" + params = [start_time.isoformat(), timestamp] + + if service: + query += " AND service = ?" + params.append(service) + + query += " ORDER BY timestamp" + + cursor.execute(query, params) + rows = cursor.fetchall() + + return [dict(row) for row in rows] + + def get_events_by_trace(self, trace_id: str) -> List[Dict[str, Any]]: + """Get events with a specific trace ID.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM events + WHERE metadata LIKE ? + ORDER BY timestamp + """, (f'%"trace_id": "{trace_id}"%',)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + # Cache + def cache_set(self, key: str, value: Any, ttl_hours: int = 24) -> None: + """Set a cache value.""" + conn = self._get_connection() + cursor = conn.cursor() + + expires_at = datetime.now() + timedelta(hours=ttl_hours) + + cursor.execute(""" + INSERT OR REPLACE INTO analysis_cache (cache_key, result, expires_at) + VALUES (?, ?, ?) + """, (key, json.dumps(value), expires_at.isoformat())) + + conn.commit() + + def cache_get(self, key: str) -> Optional[Any]: + """Get a cache value.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT result FROM analysis_cache + WHERE cache_key = ? AND expires_at > ? + """, (key, datetime.now().isoformat())) + + row = cursor.fetchone() + if row: + return json.loads(row["result"]) + + return None + + def cache_clear(self) -> None: + """Clear expired cache entries.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM analysis_cache WHERE expires_at < ? + """, (datetime.now().isoformat(),)) + + conn.commit() + + def close(self) -> None: + """Close database connection.""" + if self._connection: + self._connection.close() + self._connection = None diff --git a/src/debugai/ui/__init__.py b/src/debugai/ui/__init__.py new file mode 100644 index 0000000..2959ca8 --- /dev/null +++ b/src/debugai/ui/__init__.py @@ -0,0 +1,31 @@ +""" +DebugAI UI Module +""" + +from debugai.ui.effects import ( + UIEffects, + Themes, + Theme, + get_ui, + set_theme, + get_saved_theme, + save_theme_preference, + load_theme_preference, + THEME_MAP, + DEBUGAI_BANNER, + DEBUGAI_BANNER_SMALL, +) + +__all__ = [ + "UIEffects", + "Themes", + "Theme", + "get_ui", + "set_theme", + "get_saved_theme", + "save_theme_preference", + "load_theme_preference", + "THEME_MAP", + "DEBUGAI_BANNER", + "DEBUGAI_BANNER_SMALL", +] diff --git a/src/debugai/ui/effects.py b/src/debugai/ui/effects.py new file mode 100644 index 0000000..b32f0ad --- /dev/null +++ b/src/debugai/ui/effects.py @@ -0,0 +1,503 @@ +""" +DebugAI UI Module - Themes and Animations +""" + +import time +import random +import sys +import os +from pathlib import Path +from typing import Optional, List, Callable +from enum import Enum +from dataclasses import dataclass +from rich.console import Console +from rich.text import Text +from rich.panel import Panel +from rich.style import Style +from rich.table import Table +from rich.progress import Progress, SpinnerColumn, TextColumn + + +# Theme config file path +THEME_CONFIG_PATH = Path.home() / ".debugai_theme" + + +def save_theme_preference(theme_name: str) -> None: + """Save theme preference to config file.""" + try: + THEME_CONFIG_PATH.write_text(theme_name.lower()) + except Exception: + pass # Silently fail if can't save + + +def load_theme_preference() -> Optional[str]: + """Load saved theme preference.""" + try: + if THEME_CONFIG_PATH.exists(): + return THEME_CONFIG_PATH.read_text().strip().lower() + except Exception: + pass + return None + + +@dataclass +class Theme: + """Color theme definition""" + name: str + primary: str # Main accent color + secondary: str # Dimmed/muted version + text: str # Primary text (usually white) + dim: str # Dimmed text + success: str # Success messages + error: str # Error messages + warning: str # Warning messages + border: str # Panel borders + + +class Themes(Enum): + """Available themes""" + CYAN = Theme( + name="Cyan", + primary="cyan", + secondary="dark_cyan", + text="white", + dim="grey70", + success="green", + error="red", + warning="yellow", + border="cyan" + ) + GREEN = Theme( + name="Green", + primary="green", + secondary="dark_green", + text="white", + dim="grey70", + success="bright_green", + error="red", + warning="yellow", + border="green" + ) + RED = Theme( + name="Red", + primary="red", + secondary="dark_red", + text="white", + dim="grey70", + success="green", + error="bright_red", + warning="yellow", + border="red" + ) + PURPLE = Theme( + name="Purple", + primary="magenta", + secondary="dark_magenta", + text="white", + dim="grey70", + success="green", + error="red", + warning="yellow", + border="magenta" + ) + ORANGE = Theme( + name="Orange", + primary="dark_orange", + secondary="orange4", + text="white", + dim="grey70", + success="green", + error="red", + warning="yellow", + border="dark_orange" + ) + BLUE = Theme( + name="Blue", + primary="blue", + secondary="dark_blue", + text="white", + dim="grey70", + success="green", + error="red", + warning="yellow", + border="blue" + ) + MATRIX = Theme( + name="Matrix", + primary="bright_green", + secondary="green", + text="bright_green", + dim="dark_green", + success="bright_green", + error="red", + warning="yellow", + border="green" + ) + HACKER = Theme( + name="Hacker", + primary="bright_green", + secondary="dark_green", + text="grey93", + dim="grey50", + success="bright_green", + error="bright_red", + warning="bright_yellow", + border="bright_green" + ) + + +class UIEffects: + """Animation and visual effects for CLI""" + + GLITCH_CHARS = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~" + + def __init__(self, console: Optional[Console] = None, theme: Themes = Themes.CYAN): + self.console = console or Console() + self.theme = theme.value + self.animations_enabled = True + + def set_theme(self, theme: Themes, save: bool = True) -> None: + """Change the current theme and optionally save preference""" + self.theme = theme.value + if save: + save_theme_preference(theme.name.lower()) + + def disable_animations(self) -> None: + """Disable animations for non-interactive use""" + self.animations_enabled = False + + def enable_animations(self) -> None: + """Enable animations""" + self.animations_enabled = True + + # ============= TEXT ANIMATIONS ============= + + def glitch_text(self, text: str, duration: float = 0.5, final_color: Optional[str] = None) -> None: + """Display text with glitch animation effect""" + if not self.animations_enabled: + self.console.print(f"[{final_color or self.theme.primary}]{text}[/]") + return + + color = final_color or self.theme.primary + iterations = int(duration * 20) + text_len = len(text) + + for i in range(iterations): + glitched = "" + glitch_intensity = 1 - (i / iterations) # Decreases over time + + for char in text: + if char == " ": + glitched += " " + elif random.random() < glitch_intensity * 0.7: + glitched += random.choice(self.GLITCH_CHARS) + else: + glitched += char + + # Random color flicker during glitch + if random.random() < glitch_intensity * 0.3: + display_color = random.choice(["red", "green", "blue", "yellow", "magenta"]) + else: + display_color = color + + # Clear line and print (works better in Windows) + padding = " " * 5 # Extra padding to clear + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{display_color}]{glitched}[/{display_color}]{padding}", end="") + sys.stdout.flush() + time.sleep(0.025) + + # Final clean text on new line + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{color}]{text}[/{color}]") + + def typewriter(self, text: str, speed: float = 0.03, color: Optional[str] = None) -> None: + """Display text with typewriter effect""" + if not self.animations_enabled: + self.console.print(f"[{color or self.theme.text}]{text}[/]") + return + + color = color or self.theme.text + for i, char in enumerate(text): + self.console.print(f"[{color}]{char}[/]", end="") + + # Variable speed for natural feel + if char in ".!?": + time.sleep(speed * 5) + elif char == ",": + time.sleep(speed * 2) + elif char == " ": + time.sleep(speed * 0.5) + else: + time.sleep(speed) + + self.console.print() # Newline at end + + def reveal_text(self, text: str, duration: float = 0.3, color: Optional[str] = None) -> None: + """Reveal text character by character with fade effect""" + if not self.animations_enabled: + self.console.print(f"[{color or self.theme.text}]{text}[/]") + return + + color = color or self.theme.text + delay = duration / len(text) if text else 0 + + for i in range(len(text) + 1): + visible = text[:i] + hidden = "ā–‘" * (len(text) - i) + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{color}]{visible}[/][{self.theme.dim}]{hidden}[/]", end="") + sys.stdout.flush() + time.sleep(delay) + + sys.stdout.write(f"\r\033[K") + self.console.print(f"[{color}]{text}[/]") + + def scramble_reveal(self, text: str, duration: float = 0.5, color: Optional[str] = None) -> None: + """Reveal text with scrambling effect (like hacking scenes)""" + if not self.animations_enabled: + self.console.print(f"[{color or self.theme.primary}]{text}[/]") + return + + color = color or self.theme.primary + chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + iterations = int(duration * 30) + revealed = [False] * len(text) + + for iteration in range(iterations): + output = "" + # Reveal more characters as we progress + reveal_chance = iteration / iterations + + for i, char in enumerate(text): + if revealed[i]: + output += char + elif char == " ": + output += " " + revealed[i] = True + elif random.random() < reveal_chance * 0.3: + output += char + revealed[i] = True + else: + output += random.choice(chars) + + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{color}]{output}[/]", end="") + sys.stdout.flush() + time.sleep(duration / iterations) + + # Final clean text + sys.stdout.write(f"\r\033[K") + self.console.print(f"[{color}]{text}[/]") + + # ============= STYLED COMPONENTS ============= + + def header(self, text: str, animate: bool = True) -> None: + """Display animated header""" + border = "═" * (len(text) + 4) + + if animate and self.animations_enabled: + self.glitch_text(f"ā•”{border}ā•—", duration=0.2, final_color=self.theme.border) + self.glitch_text(f"ā•‘ {text} ā•‘", duration=0.3, final_color=self.theme.primary) + self.glitch_text(f"ā•š{border}ā•", duration=0.2, final_color=self.theme.border) + else: + self.console.print(f"[{self.theme.border}]ā•”{border}ā•—[/]") + self.console.print(f"[{self.theme.border}]ā•‘[/] [{self.theme.primary}]{text}[/] [{self.theme.border}]ā•‘[/]") + self.console.print(f"[{self.theme.border}]ā•š{border}ā•[/]") + + def subheader(self, text: str) -> None: + """Display subheader with line""" + line = "─" * 40 + self.console.print(f"\n[{self.theme.primary}]ā”Œā”€ {text}[/]") + self.console.print(f"[{self.theme.dim}]{line}[/]") + + def success(self, text: str, animate: bool = False) -> None: + """Display success message""" + self.console.print(f"[{self.theme.success}][OK] {text}[/]") + + def error(self, text: str, animate: bool = False) -> None: + """Display error message""" + self.console.print(f"[{self.theme.error}][ERROR] {text}[/]") + + def warning(self, text: str) -> None: + """Display warning message""" + self.console.print(f"[{self.theme.warning}][WARN] {text}[/]") + + def info(self, text: str, animate: bool = False) -> None: + """Display info message""" + self.console.print(f"[{self.theme.text}][INFO] {text}[/]") + + def dim(self, text: str) -> None: + """Display dimmed text""" + self.console.print(f"[{self.theme.dim}]{text}[/]") + + def panel(self, content: str, title: str = "", border_style: Optional[str] = None) -> None: + """Display a themed panel""" + style = border_style or self.theme.border + self.console.print(Panel( + content, + title=f"[bold {self.theme.primary}]{title}[/]" if title else None, + border_style=style, + padding=(1, 2) + )) + + def table(self, headers: List[str], rows: List[List[str]], title: str = "") -> None: + """Display a themed table""" + table = Table( + title=f"[bold {self.theme.primary}]{title}[/]" if title else None, + header_style=f"bold {self.theme.primary}", + border_style=self.theme.border, + show_header=True + ) + + for header in headers: + table.add_column(header) + + for row in rows: + styled_row = [f"[{self.theme.text}]{cell}[/]" for cell in row] + table.add_row(*styled_row) + + self.console.print(table) + + def progress_bar(self, description: str = "Processing") -> Progress: + """Get a themed progress bar""" + return Progress( + SpinnerColumn(style=self.theme.primary), + TextColumn(f"[{self.theme.text}]{description}...[/]"), + console=self.console + ) + + def divider(self, char: str = "─", width: int = 50) -> None: + """Display a divider line""" + self.console.print(f"[{self.theme.dim}]{char * width}[/]") + + # ============= SPECIAL EFFECTS ============= + + def loading_animation(self, text: str = "Loading", duration: float = 2.0) -> None: + """Display loading animation""" + if not self.animations_enabled: + self.console.print(f"[{self.theme.primary}]{text}...[/]") + return + + frames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "] + end_time = time.time() + duration + i = 0 + + while time.time() < end_time: + frame = frames[i % len(frames)] + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{self.theme.primary}]{frame}[/] [{self.theme.text}]{text}...[/]", end="") + sys.stdout.flush() + time.sleep(0.1) + i += 1 + + sys.stdout.write(f"\r\033[K") # Clear line + self.console.print(f"[{self.theme.success}]āœ“[/] [{self.theme.text}]{text}[/]") + + def banner(self, lines: List[str], animate: bool = True) -> None: + """Display ASCII art banner with animation""" + if animate and self.animations_enabled: + for line in lines: + self.glitch_text(line, duration=0.1, final_color=self.theme.primary) + time.sleep(0.05) + else: + for line in lines: + self.console.print(f"[{self.theme.primary}]{line}[/]") + + def status_line(self, label: str, value: str, status: str = "ok") -> None: + """Display a status line with label and value""" + status_colors = { + "ok": self.theme.success, + "error": self.theme.error, + "warning": self.theme.warning, + "info": self.theme.primary + } + color = status_colors.get(status, self.theme.text) + + dots = "." * (40 - len(label) - len(value)) + self.console.print( + f"[{self.theme.text}]{label}[/]" + f"[{self.theme.dim}]{dots}[/]" + f"[{color}]{value}[/]" + ) + + def code_block(self, code: str, language: str = "python") -> None: + """Display syntax-highlighted code block""" + from rich.syntax import Syntax + syntax = Syntax(code, language, theme="monokai", line_numbers=True) + self.console.print(Panel(syntax, border_style=self.theme.border)) + + def bullet_list(self, items: List[str], bullet: str = "•") -> None: + """Display a bullet list""" + for item in items: + self.console.print(f" [{self.theme.primary}]{bullet}[/] [{self.theme.text}]{item}[/]") + + def numbered_list(self, items: List[str]) -> None: + """Display a numbered list""" + for i, item in enumerate(items, 1): + self.console.print(f" [{self.theme.primary}]{i}.[/] [{self.theme.text}]{item}[/]") + + +# Theme name to enum mapping +THEME_MAP = { + "cyan": Themes.CYAN, + "green": Themes.GREEN, + "red": Themes.RED, + "purple": Themes.PURPLE, + "orange": Themes.ORANGE, + "blue": Themes.BLUE, + "matrix": Themes.MATRIX, + "hacker": Themes.HACKER, +} + + +def get_saved_theme() -> Themes: + """Get the saved theme or default to CYAN""" + saved = load_theme_preference() + if saved and saved in THEME_MAP: + return THEME_MAP[saved] + return Themes.CYAN + + +# Global UI instance +_ui: Optional[UIEffects] = None + + +def get_ui(theme: Optional[Themes] = None) -> UIEffects: + """Get or create the global UI instance. + + If theme is provided, uses that theme. + Otherwise, loads from saved preference or defaults to CYAN. + """ + global _ui + if _ui is None: + # Use provided theme, or load saved, or default to CYAN + actual_theme = theme if theme is not None else get_saved_theme() + _ui = UIEffects(theme=actual_theme) + return _ui + + +def set_theme(theme: Themes) -> None: + """Set the global theme""" + get_ui().set_theme(theme) + + +# ASCII Art Banners +DEBUGAI_BANNER = [ + "╔══════════════════════════════════════════════════════════╗", + "ā•‘ ____ _ _ ___ ā•‘", + "ā•‘ | _ \\ ___| |__ _ _ __ _ / \\ |_ _| ā•‘", + "ā•‘ | | | |/ _ \\ '_ \\| | | |/ _` | / _ \\ | | ā•‘", + "ā•‘ | |_| | __/ |_) | |_| | (_| | / ___ \\ | | ā•‘", + "ā•‘ |____/ \\___|_.__/ \\__,_|\\__, | /_/ \\_\\___| ā•‘", + "ā•‘ |___/ ā•‘", + "ā•‘ AI-Powered Log Analysis ā•‘", + "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•", +] + +DEBUGAI_BANNER_SMALL = [ + "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”", + "│ DebugAI v1.0.0 │", + "│ AI-Powered Debugging │", + "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜", +] diff --git a/tests/stress_test.py b/tests/stress_test.py new file mode 100644 index 0000000..e69de29