Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3a92600
Planning: SS-90 - OTel telemetry instrumentation for SmartFix
JacobMagesHaskinsContrast Apr 8, 2026
2ee933b
SS-90: Update OTel implementation beads with full architectural details
JacobMagesHaskinsContrast Apr 9, 2026
0c6ce51
SS-90: Apply Shane's plan review feedback to OTel beads
JacobMagesHaskinsContrast Apr 9, 2026
b3fc181
SS-90: Add OTel Python dependencies and config inputs
JacobMagesHaskinsContrast Apr 9, 2026
93ed335
SS-90: Implement OTel provider module
JacobMagesHaskinsContrast Apr 9, 2026
ebe2b69
SS-90: Instrument tool-run span (smartfix-run) in main.py
JacobMagesHaskinsContrast Apr 9, 2026
d85616d
SS-90: Instrument fix-vulnerability operation spans in main.py
JacobMagesHaskinsContrast Apr 9, 2026
0f43b69
SS-90: Integrate LiteLLM OTel callback for LLM call spans (contrast-2j8)
JacobMagesHaskinsContrast Apr 9, 2026
8a1a47f
SS-90: Tests for OTel instrumentation (contrast-18a)
JacobMagesHaskinsContrast Apr 9, 2026
31ad548
SS-90: Remove unused contextmanager import in test_otel_spans.py
JacobMagesHaskinsContrast Apr 10, 2026
6c7a7d9
SS-90: Add docker-compose.otel.yml for local OTel E2E verification (c…
JacobMagesHaskinsContrast Apr 10, 2026
cfa7bb4
SS-90: Sync bead state (all beads closed)
JacobMagesHaskinsContrast Apr 10, 2026
e79c4cf
SS-90: Add OTel E2E test scripts in tests/otel/
JacobMagesHaskinsContrast Apr 10, 2026
5ba5fdc
SS-90: Remove employee-management reference from run_otel_test.sh
JacobMagesHaskinsContrast Apr 10, 2026
b6141c4
SS-90: log OTel endpoint on successful initialization
JacobMagesHaskinsContrast Apr 10, 2026
b5d1b83
SS-90: fix root span dropped by calling shutdown before span ends
JacobMagesHaskinsContrast Apr 10, 2026
bb2a284
SS-90: pin chat spans to fix-vulnerability context, bypassing ADK spans
JacobMagesHaskinsContrast Apr 10, 2026
369ffac
SS-90 Refile otel_provider.py and telemetry_handler.py to src/smartfi…
JacobMagesHaskinsContrast Apr 13, 2026
5d7af87
SS-90: Fix verify_spans.py exit code capture under set -e
JacobMagesHaskinsContrast Apr 13, 2026
1a48c8a
SS-90: Document set_tracer_provider one-time-global invariant
JacobMagesHaskinsContrast Apr 13, 2026
bdd8732
SS-90: Fix vulnerabilities_total span attribute on early exit
JacobMagesHaskinsContrast Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions docker-compose.otel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# docker-compose.otel.yml
#
# Local E2E OTel verification stack for SmartFix (SS-90).
#
# Uses grafana/otel-lgtm: a single container bundling OTel Collector,
# Grafana, Tempo (traces), Loki (logs), and Prometheus (metrics).
#
# Usage:
# docker compose -f docker-compose.otel.yml up -d
# export OTEL_EXPORTER_OTLP_ENDPOINT='http://localhost:4318'
# python src/main.py
# # Browse to http://localhost:3000 -> Explore -> Tempo datasource
#
# What to verify in Tempo:
# - smartfix-run root span: session.id, vulnerabilities_total (no files_modified)
# - fix-vulnerability child spans: all contrast.finding.* and contrast.smartfix.*
# attributes (including files_modified, pr_created, pr_url)
# - chat {model} spans as children of fix-vulnerability: gen_ai.* attributes present
# - Failed LLM attempts: StatusCode=ERROR with error.type set
#
# Notes:
# - The SDK automatically appends /v1/traces to OTEL_EXPORTER_OTLP_ENDPOINT.
# - lgtm ignores auth headers; OTEL_EXPORTER_OTLP_HEADERS is optional here.
# - Works with both Docker Desktop and Rancher Desktop.

services:
lgtm:
image: grafana/otel-lgtm:latest
ports:
- "3000:3000" # Grafana UI
- "3200:3200" # Tempo HTTP API ← verify_spans.py queries this
- "4317:4317" # OTLP/gRPC
- "4318:4318" # OTLP/HTTP ← SmartFix uses this
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def __init__(self, env: Dict[str, str] = os.environ, testing: bool = False) -> N
self.GITHUB_REPOSITORY = self._get_env_var("GITHUB_REPOSITORY", required=True)
# GITHUB_SERVER_URL is automatically set by GitHub Actions (e.g., https://github.com or https://mycompany.ghe.com)
self.GITHUB_SERVER_URL = self._get_env_var("GITHUB_SERVER_URL", required=True, default="https://github.com")
self.GITHUB_RUN_ID = self._get_env_var("GITHUB_RUN_ID", required=False, default="")

# --- Contrast API Configuration ---
if testing:
Expand Down
571 changes: 315 additions & 256 deletions src/main.py

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions src/otel_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# -
# #%L
# Contrast AI SmartFix
# %%
# Copyright (C) 2026 Contrast Security, Inc.
# %%
# Contact: [email protected]
# License: Commercial
# NOTICE: This Software and the patented inventions embodied within may only be
# used as part of Contrast Security's commercial offerings. Even though it is
# made available through public repositories, use of this Software is subject to
# the applicable End User Licensing Agreement found at
# https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
# between Contrast Security and the End User. The Software may not be reverse
# engineered, modified, repackaged, sold, redistributed or otherwise used in a
# way not consistent with the End User License Agreement.
# #L%
#

"""
OTel (OpenTelemetry) Provider Module

Handles TracerProvider lifecycle for SmartFix.

Design notes:
- Enabled iff OTEL_EXPORTER_OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is set.
- OTLPSpanExporter() constructed with no args; SDK reads endpoint/headers/timeout from env.
- When disabled, the default SDK NoOpTracerProvider remains — all span calls are silent
no-ops with no guards needed in callers.
- force_flush() called before shutdown() to flush the BatchSpanProcessor background thread
before sys.exit() can kill it.
"""

import os

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

from src.utils import log

_tracer_provider = None
_shutdown_called = False


def initialize_otel(config) -> None:
"""
Initialise the OTel TracerProvider if an OTLP endpoint is configured.

Reads OTEL_EXPORTER_OTLP_ENDPOINT (or the traces-specific variant). If neither
is set, returns immediately leaving the SDK default NoOpTracerProvider in place.

Args:
config: Config object with VERSION, CONTRAST_ORG_ID, GITHUB_SERVER_URL,
GITHUB_REPOSITORY attributes.
"""
global _tracer_provider, _shutdown_called
_shutdown_called = False

endpoint = (
os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
)
if not endpoint:
log("OTel telemetry disabled: OTEL_EXPORTER_OTLP_ENDPOINT is not set")
return

try:
resource = Resource.create({
SERVICE_NAME: "smartfix",
"service.version": config.VERSION,
"contrast.org_id": config.CONTRAST_ORG_ID,
"vcs.repository.url.full": f"{config.GITHUB_SERVER_URL}/{config.GITHUB_REPOSITORY}",
"vcs.repository.name": config.GITHUB_REPOSITORY.split("/")[-1],
"vcs.owner.name": config.GITHUB_REPOSITORY.split("/")[0],
"vcs.provider.name": "github",
})

exporter = OTLPSpanExporter() # reads OTEL_EXPORTER_OTLP_ENDPOINT/HEADERS from env
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
_tracer_provider = provider
log(f"OTel telemetry enabled: exporting to {endpoint}")

except Exception as e:
log(f"OTel initialisation failed, telemetry disabled: {e}", is_warning=True)


def start_span(name: str, context=None):
"""
Return a context manager that starts a span with the given name.

Always safe to call regardless of whether OTel is initialised — returns a
no-op span when the TracerProvider has not been set.

Args:
name: The span name.
context: Optional OTel context to use as the parent. When None the
ambient current context is used (standard behaviour). Pass an
explicitly captured context to pin the parent span regardless
of whatever spans may be active at call time.
"""
return trace.get_tracer("smartfix").start_as_current_span(name, context=context)


def shutdown_otel() -> None:
"""
Flush pending spans and shut down the TracerProvider.

Calls force_flush() before shutdown() so the BatchSpanProcessor background
thread has a chance to deliver the last batch before the process exits.
Guards against double-invocation (called by both atexit and finally blocks).
"""
global _shutdown_called
if _shutdown_called:
return
_shutdown_called = True

if _tracer_provider is None:
return

try:
_tracer_provider.force_flush(timeout_millis=2000)
_tracer_provider.shutdown()
except Exception as e:
log(f"OTel shutdown error (non-fatal): {e}", is_warning=True)
5 changes: 4 additions & 1 deletion src/requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ opentelemetry-exporter-gcp-trace==1.9.0
opentelemetry-exporter-otlp-proto-common==1.37.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.37.0
# via google-adk
# via
# -r src/requirements.txt
# google-adk
opentelemetry-proto==1.37.0
# via
# opentelemetry-exporter-otlp-proto-common
Expand All @@ -307,6 +309,7 @@ opentelemetry-resourcedetector-gcp==1.9.0a0
# opentelemetry-exporter-gcp-trace
opentelemetry-sdk==1.37.0
# via
# -r src/requirements.txt
# google-adk
# google-cloud-aiplatform
# google-cloud-spanner
Expand Down
4 changes: 3 additions & 1 deletion src/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ google-generativeai==0.8.6
litellm==1.81.10
boto3==1.42.48
deprecated==1.3.1
packaging==26.0
packaging==26.0
opentelemetry-sdk==1.37.0
opentelemetry-exporter-otlp-proto-http==1.37.0
13 changes: 13 additions & 0 deletions src/smartfix/domains/scm/git_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ def commit_changes(self, message: str) -> None:
log(f"Committing changes with message: '{message}'")
run_command(["git", "commit", "-m", message]) # run_command exits on failure

def get_staged_files_count(self) -> int:
"""Returns the count of files staged for commit.

Uses 'git diff --cached --name-only' to list staged files.

Returns:
int: Number of staged files
"""
output = run_command(["git", "diff", "--cached", "--name-only"], check=False)
if not output:
return 0
return len([f for f in output.splitlines() if f.strip()])

def get_uncommitted_changed_files(self) -> List[str]:
"""Gets the list of files that have been modified but not yet committed.

Expand Down
Loading
Loading