Compare commits

...

4 Commits

Author SHA1 Message Date
Stanislav Hejny f13fd4153a Fix dependency versions
Unit test coverage / pytest (push) Successful in 1m21s
/ build-and-push (push) Successful in 1m45s
Unit test coverage / pytest (pull_request) Successful in 1m21s
2025-06-20 16:17:12 +02:00
Stanislav Hejny e280a3e13a add publish docker image action
/ build-and-push (push) Failing after 1m5s
Unit test coverage / pytest (push) Successful in 1m16s
2025-06-20 15:49:09 +02:00
Stanislav Hejny e180cbe2df try publish demo app as artefact
Unit test coverage / pytest (push) Successful in 1m17s
Unit test coverage / pytest (pull_request) Successful in 1m26s
2025-06-20 15:22:30 +02:00
Stanislav Hejny 83edca0624 Merge branch 'cleverbrag-integration-2' into develop
Unit test coverage / pytest (push) Successful in 1m16s
CI for pypl publish / publish-lib (push) Successful in 1m19s
2025-06-03 00:33:16 +01:00
5 changed files with 520 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
on: [push]
env:
REGISTRY_URL: "git.cleverthis.com"
REPOSITORY: "clevermicro/amq-adapter-python"
DOCKER_HOST: "tcp://dind:2375"
jobs:
build-and-push:
runs-on: general
services:
dind:
image: docker:dind
cmd:
- dockerd
- -H
- tcp://0.0.0.0:2375
- --tls=false
container:
image: "ghcr.io/catthehacker/ubuntu:js-22.04"
steps:
- name: checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- name: docker login
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: docker build & push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
no-cache: true
push: true
tags: ${{ env.REGISTRY_URL }}/${{ env.REPOSITORY }}:latest
+43
View File
@@ -0,0 +1,43 @@
# Use the official Python image as the base image
FROM python:3.11-slim
# Set the working directory to /app
WORKDIR /app
ENV PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY pyproject.toml .
RUN pip install --no-cache-dir build
RUN pip install --no-cache-dir .
# Install additional dependencies for demo.py
RUN pip install --no-cache-dir \
uvicorn[standard]==0.29.0 \
python-multipart==0.0.9 \
httpx==0.27.0 \
opentelemetry-api==1.34.1 \
opentelemetry-sdk==1.34.1 \
opentelemetry-exporter-otlp-proto-grpc==1.34.1 \
opentelemetry-exporter-prometheus==0.55b1 \
opentelemetry-instrumentation-fastapi==0.55b1 \
opentelemetry-instrumentation-httpx==0.55b1 \
protobuf==5.28.3
# Copy the application code
COPY ./amqp ./amqp
COPY ./otdemo ./otdemo
COPY ./demo.py .
# Create directory for uploaded files
RUN mkdir -p /tmp/clevermicro_documents
# Expose the port
EXPOSE 8000 8001
# Run the FastAPI application
CMD ["uvicorn", "demo:app", "--host", "0.0.0.0", "--port", "8000"]
+336
View File
@@ -0,0 +1,336 @@
import json
import os
import random
import shutil
import time
import httpx # For emulating remote service call
# import uvicorn
from fastapi import FastAPI, File, Form, UploadFile
# OpenTelemetry imports
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.sdk.metrics import MeterProvider
# from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from amqp.config.amq_configuration import AMQConfiguration
from otdemo.otdemo_adapter import OTDemoAmqpAdapter
# --- OpenTelemetry Configuration ---
# Configure Resource: Defines common attributes for traces and metrics
resource = Resource.create(
attributes={
"service.name": "fastapi-document-service",
"application": "CleverMicroDemo",
"environment": "development",
}
)
# Configure TracerProvider: Responsible for creating and managing Tracers
trace_provider = TracerProvider(resource=resource)
# OTLPSpanExporter: Exports spans to an OTLP collector (e.g., Jaeger) via gRPC
# Endpoint can be overridden by OTEL_EXPORTER_OTLP_ENDPOINT environment variable
otlp_exporter = OTLPSpanExporter(
endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
)
# BatchSpanProcessor: Batches spans for efficient export
trace_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Optional: ConsoleSpanExporter for local debugging to print traces to console
if os.environ.get("OTEL_DEBUG_CONSOLE_TRACES", "false").lower() == "true":
trace_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
# Set the configured TracerProvider globally
trace.set_tracer_provider(trace_provider)
# Configure MeterProvider: Responsible for creating and managing Meters for metrics
# PrometheusMetricReader: Exposes metrics via an HTTP endpoint for Prometheus to scrape
# Host and port can be overridden by OTEL_EXPORTER_PROMETHEUS_HOST and OTEL_EXPORTER_PROMETHEUS_PORT
prometheus_reader = PrometheusMetricReader(
# handler_address=os.environ.get("OTEL_EXPORTER_PROMETHEUS_HOST", "0.0.0.0"),
# handler_port=int(os.environ.get("OTEL_EXPORTER_PROMETHEUS_PORT", "8001"))
)
# MeterProvider: Uses the PrometheusMetricReader to periodically export metrics
metric_provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader])
# Set the configured MeterProvider globally
metrics.set_meter_provider(metric_provider)
# Get tracer and meter instances from the global providers
tracer = trace.get_tracer("document-service.tracer")
meter = metrics.get_meter("document-service.meter")
# --- Custom Metrics Definition ---
# Counters: For cumulative sums (e.g., total requests)
documents_uploaded_counter = meter.create_counter(
name="documents_uploaded_total", description="Total number of documents uploaded", unit="1"
)
metadata_processed_counter = meter.create_counter(
name="documents_metadata_processed_total",
description="Total number of document metadata processed",
unit="1",
)
documents_retrieved_counter = meter.create_counter(
name="documents_retrieved_total", description="Total number of documents retrieved", unit="1"
)
# Histograms: For distributions of values (e.g., request durations)
internal_api_call_duration_histogram = meter.create_histogram(
name="internal_api_call_duration_seconds",
description="Duration of internal API calls to metadata endpoint",
unit="s",
)
# --- FastAPI Application Setup ---
app = FastAPI(
title="CleverMicro Document Service Demo",
description="A FastAPI application demonstrating OpenTelemetry integration with Jaeger and Prometheus.",
)
# Instrument FastAPI: Automatically creates spans for incoming requests
FastAPIInstrumentor.instrument_app(app)
# Instrument httpx: Automatically propagates trace context for outgoing HTTP calls
HTTPXClientInstrumentor().instrument()
# Directory for storing uploaded files (temporary for demo)
UPLOAD_DIR = "/tmp/clevermicro_documents"
os.makedirs(UPLOAD_DIR, exist_ok=True)
otadapter = OTDemoAmqpAdapter(AMQConfiguration("otdemo/otdemo.properties"), app.routes)
@app.post("/api/v3/documents")
async def upload_document(
file: UploadFile = File(...), # Multipart file upload
name: str = Form(...), # Form field for document name
description: str = Form(...), # Form field for document description
):
# Create a custom span for this specific endpoint's logic
with tracer.start_as_current_span("upload_document_endpoint_logic"):
# Save the uploaded file to the temporary directory
file_location = os.path.join(UPLOAD_DIR, file.filename)
try:
with open(file_location, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"File '{file.filename}' saved to {file_location}")
# Increment the documents_uploaded_total metric
documents_uploaded_counter.add(1, {"file.name": file.filename, "document.name": name})
# Emulate a REST call to a "remote" service (which is actually this same app)
# This demonstrates trace context propagation across HTTP calls.
metadata_payload = {"name": name, "description": description}
print(
f"Emulating remote call to /api/v3/documents/metadata with: {json.dumps(metadata_payload)}"
)
start_time = time.time()
async with httpx.AsyncClient() as client:
# httpx instrumentation ensures the current trace context is propagated
response = await client.post(
# IMPORTANT: Use the actual host and port where your FastAPI app is running
# If running directly on host, use http://localhost:8000
# If running inside Docker and calling itself, use http://host.docker.internal:8000
# "http://localhost:8000/api/v3/documents/metadata",
"http://host.docker.internal:8000/api/v3/documents/metadata",
json=metadata_payload,
)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
end_time = time.time()
duration = end_time - start_time
# Record the duration of the internal API call
internal_api_call_duration_histogram.record(
duration, {"endpoint": "/api/v3/documents/metadata"}
)
print(f"Internal metadata service responded: {response.json()}")
return {
"message": "Document uploaded and metadata processing initiated",
"filename": file.filename,
"name": name,
"description": description,
}
except httpx.HTTPStatusError as e:
print(f"Error calling metadata service: {e.response.status_code} - {e.response.text}")
# Propagate the error in the trace
current_span = trace.get_current_span()
current_span.set_attribute("error.type", "HTTPStatusError")
current_span.record_exception(e)
return {
"message": "Document uploaded but metadata processing failed",
"detail": str(e),
}, 500
except Exception as e:
print(f"An unexpected error occurred during document upload: {e}")
# Propagate the error in the trace
current_span = trace.get_current_span()
current_span.set_attribute("error.type", "UnexpectedError")
current_span.record_exception(e)
return {"message": "Error processing document", "detail": str(e)}, 500
@app.post("/api/v3/documents/metadata")
async def process_document_metadata(metadata: dict):
# This endpoint is automatically instrumented by FastAPIInstrumentor
# A new span will be created, and its parent will be the span from the calling /api/v3/documents
# due to trace context propagation via httpx.
print(f"Received metadata for processing: {json.dumps(metadata, indent=2)}")
# Increment the metadata_processed_total metric
metadata_processed_counter.add(1, {"document.name": metadata.get("name")})
# Simulate some processing time to make traces more interesting
time.sleep(random.uniform(0.05, 0.2))
return {"status": "metadata processed", "received_data": metadata}
@app.get("/api/v3/documents")
async def get_documents():
# Create a custom span for the logic within this endpoint
with tracer.start_as_current_span("get_documents_endpoint_logic"):
documents = []
# Generate several random documents to emulate a list
for i in range(random.randint(2, 5)):
documents.append(
{
"id": f"doc-{random.randint(1000, 9999)}",
"name": f"Document {random.randint(1, 100)}",
"description": f"Description for document {random.randint(1, 1000)}",
"uploaded_at": time.strftime(
"%Y-%m-%dT%H:%M:%SZ",
time.gmtime(time.time() - random.randint(0, 86400 * 30)),
),
}
)
# Increment the documents_retrieved_total metric
documents_retrieved_counter.add(1)
# Simulate some processing time
time.sleep(random.uniform(0.01, 0.1))
return {"documents": documents}
# --- How to Run the Demo ---
# 1. Save the code:
# Save the Python code above as `main.py` in a new directory.
# 2. Create `requirements.txt`:
# Create a file named `requirements.txt` in the same directory with the following content:
# ```
# fastapi==0.111.0
# uvicorn[standard]==0.29.0
# python-multipart==0.0.9
# httpx==0.27.0
# opentelemetry-api==1.25.0
# opentelemetry-sdk==1.25.0
# opentelemetry-exporter-otlp-proto-grpc==1.25.0 # For Jaeger traces
# opentelemetry-exporter-prometheus==1.25.0 # For Prometheus metrics
# opentelemetry-instrumentation-fastapi==0.46b0 # IMPORTANT: Check compatibility with your FastAPI version!
# opentelemetry-instrumentation-httpx==0.46b0 # IMPORTANT: Check compatibility with your httpx version!
# protobuf==4.25.3 # Required by OTLP exporter, ensure version compatibility if issues arise
# ```
# 3. Install dependencies:
# Open your terminal in the directory where you saved `main.py` and `requirements.txt`, then run:
# `pip install -r requirements.txt`
# 4. Run Jaeger and Prometheus (using Docker Compose for simplicity):
# Create a file named `docker-compose.yaml` in the same directory:
# ```yaml
# version: '3.8'
# services:
# jaeger:
# image: jaegertracing/all-in-one:latest
# ports:
# - "6831:6831/udp" # UDP Thrift
# - "14268:14268" # HTTP Thrift
# - "14250:14250" # gRPC
# - "4317:4317" # OTLP gRPC collector (for traces from our app)
# - "4318:4318" # OTLP HTTP collector
# - "16686:16686" # Jaeger UI
# environment:
# - COLLECTOR_OTLP_ENABLED=true # Enable OTLP reception
#
# prometheus:
# image: prom/prometheus:latest
# volumes:
# - ./prometheus.yml:/etc/prometheus/prometheus.yml # Mount Prometheus config
# ports:
# - "9090:9090" # Prometheus UI
# command:
# - '--config.file=/etc/prometheus/prometheus.yml'
# ```
# Create a file named `prometheus.yml` in the same directory:
# ```yaml
# global:
# scrape_interval: 15s # How frequently to scrape targets
#
# scrape_configs:
# - job_name: 'fastapi_app'
# # The 'metrics' endpoint of our FastAPI app exposed by PrometheusMetricReader
# # 'host.docker.internal' allows Docker containers to connect to the host machine's localhost
# static_configs:
# - targets: ['host.docker.internal:8001']
# ```
# Start Jaeger and Prometheus:
# `docker-compose up -d` (the `-d` runs them in the background)
# 5. Run the FastAPI application:
# In your terminal (where `main.py` is located), run:
# `uvicorn main:app --host 0.0.0.0 --port 8000`
# (Optional: To see traces printed to console, set `export OTEL_DEBUG_CONSOLE_TRACES=true` before `uvicorn`)
# --- Accessing the UIs ---
# * **Jaeger UI:** Open your web browser and go to `http://localhost:16686`
# * **Prometheus UI:** Open your web browser and go to `http://localhost:9090`
# * **FastAPI App (Root):** `http://localhost:8000`
# * **FastAPI Metrics Endpoint:** `http://localhost:8001/metrics` (Prometheus scrapes this, you can also view it directly)
# --- How to Test and Observe ---
# 1. Ensure all services are running (`docker-compose ps` should show `jaeger` and `prometheus` up, and `uvicorn` running in your terminal).
# 2. **Trigger GET requests:**
# Open your browser or use `curl` to hit: `http://localhost:8000/api/v3/documents`
# Refresh a few times.
# 3. **Trigger POST requests (for file upload and internal call):**
# You'll need a tool like Postman, Insomnia, or `curl` (more complex for multipart) for this.
# **Using Postman/Insomnia:**
# * Method: `POST`
# * URL: `http://localhost:8000/api/v3/documents`
# * Body: Select `form-data`
# * Add a key `file`, Type `File`, Value: Choose any small file from your computer (e.g., a `.txt` or `.png`).
# * Add a key `name`, Type `Text`, Value: `My Demo Document`
# * Add a key `description`, Type `Text`, Value: `A test document for OpenTelemetry.`
# * Send the request multiple times.
# 4. **Observe in Jaeger UI (`http://localhost:16686`):**
# * Select "Service": `fastapi-document-service`
# * Click "Find Traces".
# * You should see traces for `/api/v3/documents` (POST) and `/api/v3/documents` (GET).
# * Crucially, for POST requests, expand the trace: you will see the main `/api/v3/documents` span, and nested within it, a child span for the `POST /api/v3/documents/metadata` HTTP request. This demonstrates automatic context propagation.
# 5. **Observe in Prometheus UI (`http://localhost:9090`):**
# * Go to the "Graph" tab.
# * In the expression bar, type and execute queries like:
# * `documents_uploaded_total`
# * `documents_metadata_processed_total`
# * `documents_retrieved_total`
# * `internal_api_call_duration_seconds_sum`
# * `internal_api_call_duration_seconds_count`
# * You will see the values of these metrics increasing with your requests.
# This setup provides a clear, runnable demo for OpenTelemetry traces and metrics in a FastAPI application, including automatic context propagation for internal HTTP calls.
+41
View File
@@ -0,0 +1,41 @@
# ====================================================================
# CleverMicro AMQ Adapter settings
# ====================================================================
[CleverMicro-AMQ]
# CleverMicro AMQ Adapter settings that identify this AMQ Adapter instance to other adapters
# A name to identify this service for the AMQ Adapter
cm.amq-adapter.service-name=otdemo
# The CleverMicro AMQ Adapter instance ID, used to identify this instance in the CleverMicro AMQ Adapter cluster
cm.amq-adapter.generator-id=1
# Route mapping. For route detail description see section 3.1 in Onboarding Guide for Python:
# https://docs.cleverthis.com/en/architecture/microservices/onboarding/python
# NOTE: this is a comma-separated list of route mappings (multiple, comma-separated routes are allowed here)
cm.amq-adapter.route-mapping=otdemo::otdemo:otdemo.localhost.#:5:0
# Indicate if AMQ Adapter should respond with HTTP 401 Unauthorized if the request does not contain valid JWT token
cm.amq-adapter.require-authenticated-user=false
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
cm.dispatch.use-dlq=true
cm.dispatch.amq-host=rabbitmq
cm.dispatch.amq-port=5672
cm.dispatch.amq-port-tls=5671
cm.dispatch.download-dir=/tmp/downloaded_files
# Not used for CleveSwarm nor CleverBRAG or in tight coupling mode.
# applicable only in loose coupling mode, specify the destination where to forward the REST request
#cm.dispatch.service-host=cleverthis-service-name.localhost
#cm.dispatch.service-port=8080
# Backpressure monitor settings
# Define maximum number of messages of being processed in parallel before triggering backpressure OVERLOAD alert
cm.backpressure.threshold=5
# Define maximum CPU usage (%) before triggering backpressure OVERLOAD alert
cm.backpressure.threshold-cpu-overload=90
# Define maximum CPU usage (%) before triggering backpressure IDLE alert
cm.backpressure.threshold-cpu-idle=10
# Define the time window between the Backpressure reports, in seconds
cm.backpressure.time-window=10
# Define the time window length, in seconds, during which, if CPU usage is consistently above the threshold, the system will trigger OVERLOAD alert
cm.backpressure.cpu-overload-duration=30
# Define the time window length, in seconds, during which, if no request is made to the Service, the system will trigger IDLE alert
cm.backpressure.cpu-idle-duration=30
+59
View File
@@ -0,0 +1,59 @@
"""
This module provides the CleverSwarm AMQP Adapter for handling API requests via AMQP.
The code IS MEANT to be part of CleverSwarm codebase, and has direct dependencies on the CleverSwarm codebase.
It is NOT MEANT to be used as a standalone module. It WILL NOT run outside the CleverSwarm endpoints context.
"""
from threading import Thread
from typing import Any, List
# CleverSwarm specific imports
from fastapi.routing import APIRoute
from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter
from amqp.config.amq_configuration import AMQConfiguration
from amqp.service.amq_service import AMQService
PREFERRED_SUFFIX = "_json"
# =================================================================================================
# ======================== C l e v e r S w a r m A M Q A d a p t e r ========================
# =================================================================================================
class OTDemoAmqpAdapter(CleverThisServiceAdapter):
"""
CleverSwarm AMQP Adapter for CleverSwarm API. Implements the CleverSwarm specific logic that overrides
common logic defined by AMQ Adapter library. The specific logic here is:
1. Extract subject from JWT token and convert it to UserSchema to avail authorized user argument
2. Find JSON wrapper for endpoint Callable, and if exists, use it instead of the original endpoint.
"""
def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]):
super().__init__(amq_configuration, routes=routes, session=None)
_amq_service: AMQService = AMQService(amq_configuration, self)
self.thread = Thread(target=_amq_service.run)
self.thread.start()
async def get_current_user(self, token: str) -> str:
"""
Overrides the default get_current_user method to use the token from the AMQP message.
In CleverSwarm context, it means simply to call provided get_current_user function.
:param token: JWT token from the AMQP message
:return: UserSchema object
"""
return token
def get_endpoint(self, endpoint: Any) -> Any:
"""
If the specific endpoint has a matching JSON wrapper (identified as {endpoint.name}_json ),
then use it as preferred way to invoke the endpoint.
:param endpoint: the Callable for the endpoint
:return: new Callable representing the JSON wrapper endpoint, if it exists or the original Callable.
"""
return endpoint