diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ed14700..44e5a21 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,17 +1,40 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# Dependabot configuration for automated dependency updates +# See: https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - - package-ecosystem: "pip" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "pip" + directory: "/" schedule: interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Jerusalem" + groups: + python-dependencies: + patterns: + - "*" + commit-message: + prefix: "deps" + labels: + - "dependencies" + - "python" + open-pull-requests-limit: 5 + - package-ecosystem: "github-actions" - # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.) directory: "/" schedule: - # Check for updates to GitHub Actions every weekday interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Jerusalem" + groups: + github-actions: + patterns: + - "*" + commit-message: + prefix: "ci" + labels: + - "dependencies" + - "github-actions" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c53a7b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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 -r requirements.txt + pip install pytest pytest-cov future requests + + - name: Install package in development mode + run: pip install -e . + + - name: Run unit tests with coverage + run: | + pytest --cov-report=term-missing --cov=logzio tests/ -v --ignore=tests/e2e/ + + - name: Upload coverage report + if: matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: .coverage + retention-days: 5 + + e2e-integration: + name: E2E Integration Test + runs-on: ubuntu-latest + needs: [test] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest requests + + - name: Install package in development mode + run: pip install -e . + + - name: Generate unique ENV_ID + id: env-id + run: echo "value=e2e-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_OUTPUT + + - name: Run E2E integration test + env: + LOGZIO_TOKEN: ${{ secrets.LOGZIO_TOKEN }} + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + ENV_ID: ${{ steps.env-id.outputs.value }} + run: | + pytest tests/e2e/test_logzio_e2e.py -v --tb=long + + ci-success: + name: CI Success + runs-on: ubuntu-latest + needs: [test, e2e-integration] + if: always() + steps: + - name: Check all jobs passed + run: | + if [[ "${{ needs.test.result }}" != "success" ]]; then + echo "Test job failed" + exit 1 + fi + if [[ "${{ needs.e2e-integration.result }}" != "success" ]]; then + echo "E2E integration job failed" + exit 1 + fi + echo "All CI checks passed! ✅" diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..c5d8d51 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,53 @@ +name: Dependabot Auto-Merge + +# This workflow runs AFTER CI completes for Dependabot PRs. +# It waits for CI to pass, then enables auto-merge. + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-Merge Dependabot PR + runs-on: ubuntu-latest + # Run if: + # 1. CI passed + # 2. It was triggered by Dependabot + # 3. It was a pull request + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.actor.login == 'dependabot[bot]' && + github.event.workflow_run.event == 'pull_request' + + steps: + - name: Checkout for metadata + uses: actions/checkout@v4 + + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge patch/minor updates + if: | + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: | + echo "Enabling auto-merge for ${{ steps.metadata.outputs.dependency-names }} (${{ steps.metadata.outputs.update-type }})" + gh pr merge --auto --merge "$HEAD_BRANCH" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + + - name: Skip major updates + if: steps.metadata.outputs.update-type == 'version-update:semver-major' + run: | + echo "Major version update for ${{ steps.metadata.outputs.dependency-names }} - review required" diff --git a/.github/workflows/dependabot-notifications.yml b/.github/workflows/dependabot-notifications.yml new file mode 100644 index 0000000..768d56e --- /dev/null +++ b/.github/workflows/dependabot-notifications.yml @@ -0,0 +1,184 @@ +name: Dependabot Notifications + +# This workflow sends Slack notifications when CI completes for Dependabot PRs. +# It uses workflow_run to trigger AFTER the CI workflow finishes. + + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + contents: read + pull-requests: read + actions: read + +jobs: + notify-success: + name: Notify Success + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.actor.login == 'dependabot[bot]' + + steps: + - name: Get PR information + id: pr-info + uses: actions/github-script@v7 + with: + script: | + const { data: pullRequests } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}` + }); + + if (pullRequests.length > 0) { + const pr = pullRequests[0]; + core.setOutput('pr_number', pr.number); + core.setOutput('pr_title', pr.title); + core.setOutput('pr_url', pr.html_url); + core.setOutput('found', 'true'); + } else { + core.setOutput('found', 'false'); + } + + - name: Send Slack notification (Success) + if: steps.pr-info.outputs.found == 'true' + run: | + curl -X POST -H 'Content-type: application/json' --data '{ + "attachments": [{ + "color": "#36a64f", + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": "✅ Dependabot Update - CI Passed", "emoji": true} + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Repository:*\n${{ github.repository }}"}, + {"type": "mrkdwn", "text": "*PR:*\n<${{ steps.pr-info.outputs.pr_url }}|#${{ steps.pr-info.outputs.pr_number }}>"} + ] + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "${{ steps.pr-info.outputs.pr_title }}"} + }, + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": "Auto-merge enabled • logzio-python-handler CI"}] + } + ] + }] + }' "$SLACK_WEBHOOK" + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + + notify-failure: + name: Notify Failure + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.actor.login == 'dependabot[bot]' + + steps: + - name: Get PR information + id: pr-info + uses: actions/github-script@v7 + with: + script: | + const { data: pullRequests } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}` + }); + + if (pullRequests.length > 0) { + const pr = pullRequests[0]; + core.setOutput('pr_number', pr.number); + core.setOutput('pr_title', pr.title); + core.setOutput('pr_url', pr.html_url); + core.setOutput('found', 'true'); + } else { + core.setOutput('found', 'false'); + } + + - name: Get workflow run URL + id: run-url + run: | + echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT + + - name: Send Slack notification (Failure) + if: steps.pr-info.outputs.found == 'true' + run: | + curl -X POST -H 'Content-type: application/json' --data '{ + "attachments": [{ + "color": "#ff0000", + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": "🚨 Dependabot Update - CI Failed", "emoji": true} + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Repository:*\n${{ github.repository }}"}, + {"type": "mrkdwn", "text": "*PR:*\n<${{ steps.pr-info.outputs.pr_url }}|#${{ steps.pr-info.outputs.pr_number }}>"} + ] + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "${{ steps.pr-info.outputs.pr_title }}\n\n<${{ steps.run-url.outputs.url }}|View CI Run> to investigate"} + }, + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": "Manual intervention required • logzio-python-handler CI"}] + } + ] + }] + }' "$SLACK_WEBHOOK" + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + notify-merged: + name: Notify Merged + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + contains(github.event.workflow_run.head_commit.message, 'dependabot') + + steps: + - name: Send Slack notification (Merged) + run: | + curl -X POST -H 'Content-type: application/json' --data '{ + "attachments": [{ + "color": "#2eb886", + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": "🎉 Dependencies Updated Successfully", "emoji": true} + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Repository:*\n${{ github.repository }}"}, + {"type": "mrkdwn", "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha }}|${{ github.event.workflow_run.head_sha }}>"} + ] + }, + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": "Merged to main • logzio-python-handler CI"}] + } + ] + }] + }' "$SLACK_WEBHOOK" + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + diff --git a/README.md b/README.md index 7687fd4..a53f129 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ pip install logzio-python-handler[opentelemetry-logging] ## Tested Python Versions -Travis CI will build this handler and test against: +CI will build this handler and test against: - "3.5" - "3.6" @@ -49,6 +49,9 @@ Travis CI will build this handler and test against: - "3.9" - "3.10" - "3.11" +- "3.12" +- "3.13" +- "3.14" We can't ensure compatibility to any other version, as we can't test it automatically. diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_logzio_e2e.py b/tests/e2e/test_logzio_e2e.py new file mode 100644 index 0000000..49693c7 --- /dev/null +++ b/tests/e2e/test_logzio_e2e.py @@ -0,0 +1,167 @@ +""" +E2E Integration Tests for logzio-python-handler + +Sends logs using the handler and validates they arrive via Logz.io API. + +Required Environment Variables: + LOGZIO_TOKEN: Shipping token for sending logs + LOGZIO_API_KEY: API token for querying logs + ENV_ID: Unique identifier for this test run +""" + +import json +import logging +import os +import re +import time +import requests +import pytest + +from logzio.handler import LogzioHandler + +BASE_LOGZIO_API_URL = os.getenv("LOGZIO_API_URL", "https://api.logz.io/v1") + + +def get_env_or_skip(var_name: str) -> str: + """Get environment variable or skip test if not set.""" + value = os.environ.get(var_name) + if not value: + pytest.skip(f"Environment variable {var_name} is not set") + return value + + +def format_query(query: str) -> str: + """Format query for Logz.io search API.""" + return json.dumps({ + "query": { + "query_string": { + "query": query + } + }, + "from": 0, + "size": 100, + "sort": [{"@timestamp": {"order": "desc"}}] + }) + + +def fetch_logs(api_key: str, query: str): + """Fetch logs from Logz.io API.""" + url = f"{BASE_LOGZIO_API_URL}/search" + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-TOKEN": api_key + } + + print(f"Sending API request to {url}") + print(f"Query: {query}") + + response = requests.post( + url, + headers=headers, + data=format_query(query), + timeout=30 + ) + + if response.status_code != 200: + raise Exception(f"Unexpected status code: {response.status_code}, body: {response.text}") + + return response.json() + + +def send_test_log(token: str, env_id: str, message: str): + """Send a test log using the logzio handler.""" + handler = LogzioHandler( + token=token, + logzio_type=env_id, + logs_drain_timeout=2, + debug=True, + backup_logs=False + ) + + logger = logging.getLogger(f"e2e-test-{env_id}") + logger.setLevel(logging.INFO) + logger.handlers = [] + logger.addHandler(handler) + + logger.info(message, extra={"env_id": env_id, "test_source": "python-handler-e2e"}) + + handler.flush() + time.sleep(3) + + +class TestLogzioLogs: + """E2E tests for logzio-python-handler.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Set up test fixtures.""" + self.token = get_env_or_skip("LOGZIO_TOKEN") + self.api_key = get_env_or_skip("LOGZIO_API_KEY") + self.env_id = get_env_or_skip("ENV_ID") + + def test_logs_received(self): + """Test that logs are received and have required fields.""" + test_message = f"E2E test message from python handler - {self.env_id}" + send_test_log(self.token, self.env_id, test_message) + + print("Waiting for log ingestion...") + time.sleep(180) + + query = f"env_id:{self.env_id} AND type:{self.env_id}" + response = fetch_logs(self.api_key, query) + + total = response.get("hits", {}).get("total", 0) + if total == 0: + pytest.fail("No logs found") + + hits = response.get("hits", {}).get("hits", []) + for hit in hits: + source = hit.get("_source", {}) + + required_fields = ["message", "logger", "log_level", "type", "@timestamp"] + missing_fields = [f for f in required_fields if not source.get(f)] + + if missing_fields: + print(f"Log missing fields: {missing_fields}") + print(f"Log content: {json.dumps(source, indent=2)}") + pytest.fail(f"Missing required log fields: {missing_fields}") + + print(f"✅ Found {total} logs with all required fields") + + def test_log_content_matches(self): + """Test that log content matches what was sent.""" + test_message = f"Content validation test - {self.env_id}" + send_test_log(self.token, self.env_id, test_message) + + print("Waiting for log ingestion...") + time.sleep(180) + + query = f"env_id:{self.env_id}" + response = fetch_logs(self.api_key, query) + + hits = response.get("hits", {}).get("hits", []) + if not hits: + pytest.fail("No logs found for env_id") + + # Find the log with matching message using regex + pattern = re.compile(r"Content\s+validation\s+test") + matching_log = None + for hit in hits: + source = hit.get("_source", {}) + message = source.get("message", "") + if pattern.search(message): + matching_log = source + break + + if not matching_log: + pytest.fail("Test log with 'Content validation test' not found in message field") + + assert matching_log.get("env_id") == self.env_id, "env_id mismatch" + assert matching_log.get("test_source") == "python-handler-e2e", "test_source mismatch" + + print("✅ Log content matches expected values") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_logzioHandler.py b/tests/test_logzioHandler.py index 516b764..fc445be 100644 --- a/tests/test_logzioHandler.py +++ b/tests/test_logzioHandler.py @@ -11,6 +11,11 @@ class TestLogzioHandler(TestCase): def setUp(self): self.handler = LogzioHandler('moo') + def _remove_version_specific_fields(self, message): + """Remove fields that vary by Python version (e.g., taskName added in 3.12).""" + message.pop('taskName', None) + return message + def test_json(self): formatter = logging.Formatter( '{ "appname":"%(name)s", "functionName":"%(funcName)s", \"lineNo":"%(lineno)d", "severity":"%(' @@ -30,6 +35,7 @@ def test_json(self): formatted_message = self.handler.format_message(record) formatted_message["@timestamp"] = None + self._remove_version_specific_fields(formatted_message) self.assertDictEqual( formatted_message, @@ -44,7 +50,6 @@ def test_json(self): 'message': 'this is a test: moo.', 'path_name': 'handler_test.py', 'severity': 'NOTSET', - 'taskName': None, 'type': 'python' } ) @@ -63,6 +68,7 @@ def test_string(self): formatted_message = self.handler.format_message(record) formatted_message["@timestamp"] = None + self._remove_version_specific_fields(formatted_message) self.assertDictEqual( formatted_message, @@ -73,7 +79,6 @@ def test_string(self): 'logger': 'my-logger', 'message': 'this is a test: moo.', 'path_name': 'handler_test.py', - 'taskName': None, 'type': 'python' } ) @@ -94,6 +99,7 @@ def test_extra_formatting(self): record.__dict__["module"] = "testing" formatted_message = self.handler.format_message(record) formatted_message["@timestamp"] = None + self._remove_version_specific_fields(formatted_message) self.assertDictEqual( formatted_message, @@ -104,7 +110,6 @@ def test_extra_formatting(self): 'logger': 'my-logger', 'message': 'this is a test: moo.', 'path_name': 'handler_test.py', - 'taskName': None, 'type': 'python', 'extra_key': 'extra_value' } @@ -124,6 +129,7 @@ def test_format_string_message(self): formatted_message = self.handler.format_message(record) formatted_message["@timestamp"] = None + self._remove_version_specific_fields(formatted_message) self.assertDictEqual( formatted_message, @@ -134,7 +140,6 @@ def test_format_string_message(self): 'logger': 'my-logger', 'message': 'this is a test: moo.', 'path_name': 'handler_test.py', - 'taskName': None, 'type': 'python' } ) @@ -161,6 +166,7 @@ def test_exception(self): formatted_message = self.handler.format_message(record) formatted_message["@timestamp"] = None + self._remove_version_specific_fields(formatted_message) formatted_message["exception"] = formatted_message["exception"].replace(os.path.abspath(__file__), "") formatted_message["exception"] = re.sub(r", line \d+", "", formatted_message["exception"]) @@ -175,7 +181,6 @@ def test_exception(self): 'message': 'exception test:', 'exception': 'Traceback (most recent call last):\n\n File "", in test_exception\n raise ValueError("oops.")\n\nValueError: oops.\n', 'path_name': 'handler_test.py', - 'taskName': None, 'type': 'python', 'tags': ['staging', 'experimental'] }, diff --git a/tox.ini b/tox.ini index bf349a1..a94d327 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,8 @@ deps = pytest pytest-cov passenv = CI,TRAVIS,TRAVIS_* -commands = pytest --cov-report term-missing --cov logzio tests -v +# Exclude E2E tests from regular test runs (they require secrets) +commands = pytest --cov-report term-missing --cov logzio tests -v --ignore=tests/e2e/ [testenv:flake8] basepython = python3.9