diff --git a/.env.template b/.env.template index ad6b09e..3c84001 100644 --- a/.env.template +++ b/.env.template @@ -59,3 +59,7 @@ CSV_LOADER_DATA_DIR_PATH="./data" ## PDF Loader Settings PDF_LOADER_DATA_DIR_PATH="./data" + +## OpenTelemetry Settings +OTEL_SERVICE_NAME="template-langgraph" +OTEL_COLLECTOR_ENDPOINT="http://localhost:4317" diff --git a/docker-compose.yml b/docker-compose.yml index e412585..ec94898 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,3 +31,19 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: db restart: always + jaeger: + image: jaegertracing/all-in-one:1.72.0 + container_name: jaeger + environment: + - SPAN_STORAGE_TYPE=elasticsearch + - ES_SERVER_URLS=http://elasticsearch:9200 + - LOG_LEVEL=debug + ports: + - "16686:16686" + - "4317:4317" + - "4318:4318" + - "5778:5778" + - "9411:9411" + depends_on: + - elasticsearch + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index e2aedd5..e03a08d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "langgraph>=0.6.2", "langgraph-supervisor>=0.0.29", "openai>=1.98.0", + "opentelemetry-api>=1.36.0", + "opentelemetry-exporter-otlp>=1.36.0", + "opentelemetry-sdk>=1.36.0", "psycopg2-binary>=2.9.10", "pydantic-settings>=2.9.1", "pypdf>=5.9.0", diff --git a/scripts/otel_operator.py b/scripts/otel_operator.py new file mode 100644 index 0000000..adad8ad --- /dev/null +++ b/scripts/otel_operator.py @@ -0,0 +1,56 @@ +import logging +import time + +import typer +from dotenv import load_dotenv + +from template_langgraph.loggers import get_logger +from template_langgraph.utilities.otel_helpers import OtelWrapper + +# Initialize the Typer application +app = typer.Typer( + add_completion=False, + help="OTEL operator CLI", +) + +# Set up logging +logger = get_logger(__name__) + + +@app.command() +def run( + query: str = typer.Option( + "What is the weather like today?", + "--query", + "-q", + help="Query to run against the Ollama model", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + otel_wrapper = OtelWrapper() + otel_wrapper.initialize() + + logger.info("Running...") + tracer = otel_wrapper.get_tracer(name=__name__) + with tracer.start_as_current_span("otel_operator_run"): + logger.info(f"Query: {query}") + # Simulate some work + response = {"content": "It's sunny!"} + time.sleep(1) # Simulate processing time + logger.info(f"Response: {response['content']}") + + +if __name__ == "__main__": + load_dotenv( + override=True, + verbose=True, + ) + app() diff --git a/template_langgraph/utilities/otel_helpers.py b/template_langgraph/utilities/otel_helpers.py new file mode 100644 index 0000000..8406fd8 --- /dev/null +++ b/template_langgraph/utilities/otel_helpers.py @@ -0,0 +1,54 @@ +from functools import lru_cache + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + otel_service_name: str = "" + otel_collector_endpoint: str = "" + + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + +@lru_cache +def get_otel_settings() -> Settings: + """Get OpenTelemetry settings.""" + return Settings() + + +class OtelWrapper: + def __init__( + self, + settings: Settings = None, + ): + if settings is None: + settings = get_otel_settings() + self.settings = settings + + def initialize(self): + provider = TracerProvider( + resource=Resource( + attributes={ + "service.name": self.settings.otel_service_name, + } + ) + ) + otlp_exporter = OTLPSpanExporter( + endpoint=self.settings.otel_collector_endpoint, + ) + provider.add_span_processor( + span_processor=BatchSpanProcessor(otlp_exporter), + ) + trace.set_tracer_provider(provider) + + def get_tracer(self, name: str): + return trace.get_tracer(name) diff --git a/uv.lock b/uv.lock index 580e371..13f9733 100644 --- a/uv.lock +++ b/uv.lock @@ -1160,6 +1160,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "greenlet" version = "3.2.3" @@ -2810,6 +2822,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl", hash = "sha256:02f20bcacf666e1333b6b1f04e647dc1d5111f86b8e510238fcc56d7762cda8c", size = 65564, upload-time = "2025-07-29T15:11:47.998Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7f/d31294ac28d567a14aefd855756bab79fed69c5a75df712f228f10c47e04/opentelemetry_exporter_otlp-1.36.0.tar.gz", hash = "sha256:72f166ea5a8923ac42889337f903e93af57db8893de200369b07401e98e4e06b", size = 6144, upload-time = "2025-07-29T15:12:07.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/a2/8966111a285124f3d6156a663ddf2aeddd52843c1a3d6b56cbd9b6c3fd0e/opentelemetry_exporter_otlp-1.36.0-py3-none-any.whl", hash = "sha256:de93b7c45bcc78296998775d52add7c63729e83ef2cd6560730a6b336d7f6494", size = 7018, upload-time = "2025-07-29T15:11:50.498Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/da/7747e57eb341c59886052d733072bc878424bf20f1d8cf203d508bbece5b/opentelemetry_exporter_otlp_proto_common-1.36.0.tar.gz", hash = "sha256:6c496ccbcbe26b04653cecadd92f73659b814c6e3579af157d8716e5f9f25cbf", size = 20302, upload-time = "2025-07-29T15:12:07.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ed/22290dca7db78eb32e0101738366b5bbda00d0407f00feffb9bf8c3fdf87/opentelemetry_exporter_otlp_proto_common-1.36.0-py3-none-any.whl", hash = "sha256:0fc002a6ed63eac235ada9aa7056e5492e9a71728214a61745f6ad04b923f840", size = 18349, upload-time = "2025-07-29T15:11:51.327Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/6f/6c1b0bdd0446e5532294d1d41bf11fbaea39c8a2423a4cdfe4fe6b708127/opentelemetry_exporter_otlp_proto_grpc-1.36.0.tar.gz", hash = "sha256:b281afbf7036b325b3588b5b6c8bb175069e3978d1bd24071f4a59d04c1e5bbf", size = 23822, upload-time = "2025-07-29T15:12:08.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/67/5f6bd188d66d0fd8e81e681bbf5822e53eb150034e2611dd2b935d3ab61a/opentelemetry_exporter_otlp_proto_grpc-1.36.0-py3-none-any.whl", hash = "sha256:734e841fc6a5d6f30e7be4d8053adb703c70ca80c562ae24e8083a28fadef211", size = 18828, upload-time = "2025-07-29T15:11:52.235Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/85/6632e7e5700ba1ce5b8a065315f92c1e6d787ccc4fb2bdab15139eaefc82/opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e", size = 16213, upload-time = "2025-07-29T15:12:08.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/41/a680d38b34f8f5ddbd78ed9f0042e1cc712d58ec7531924d71cb1e6c629d/opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902", size = 18752, upload-time = "2025-07-29T15:11:53.164Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/02/f6556142301d136e3b7e95ab8ea6a5d9dc28d879a99f3dd673b5f97dca06/opentelemetry_proto-1.36.0.tar.gz", hash = "sha256:0f10b3c72f74c91e0764a5ec88fd8f1c368ea5d9c64639fb455e2854ef87dd2f", size = 46152, upload-time = "2025-07-29T15:12:15.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/57/3361e06136225be8180e879199caea520f38026f8071366241ac458beb8d/opentelemetry_proto-1.36.0-py3-none-any.whl", hash = "sha256:151b3bf73a09f94afc658497cf77d45a565606f62ce0c17acb08cd9937ca206e", size = 72537, upload-time = "2025-07-29T15:12:02.243Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/85/8567a966b85a2d3f971c4d42f781c305b2b91c043724fa08fd37d158e9dc/opentelemetry_sdk-1.36.0.tar.gz", hash = "sha256:19c8c81599f51b71670661ff7495c905d8fdf6976e41622d5245b791b06fa581", size = 162557, upload-time = "2025-07-29T15:12:16.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/59/7bed362ad1137ba5886dac8439e84cd2df6d087be7c09574ece47ae9b22c/opentelemetry_sdk-1.36.0-py3-none-any.whl", hash = "sha256:19fe048b42e98c5c1ffe85b569b7073576ad4ce0bcb6e9b4c6a39e890a6c45fb", size = 119995, upload-time = "2025-07-29T15:12:03.181Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/31/67dfa252ee88476a29200b0255bda8dfc2cf07b56ad66dc9a6221f7dc787/opentelemetry_semantic_conventions-0.57b0.tar.gz", hash = "sha256:609a4a79c7891b4620d64c7aac6898f872d790d75f22019913a660756f27ff32", size = 124225, upload-time = "2025-07-29T15:12:17.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/75/7d591371c6c39c73de5ce5da5a2cc7b72d1d1cd3f8f4638f553c01c37b11/opentelemetry_semantic_conventions-0.57b0-py3-none-any.whl", hash = "sha256:757f7e76293294f124c827e514c2a3144f191ef175b069ce8d1211e1e38e9e78", size = 201627, upload-time = "2025-07-29T15:12:04.174Z" }, +] + [[package]] name = "orjson" version = "3.11.1" @@ -4545,6 +4657,9 @@ dependencies = [ { name = "langgraph" }, { name = "langgraph-supervisor" }, { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "pypdf" }, @@ -4586,6 +4701,9 @@ requires-dist = [ { name = "langgraph", specifier = ">=0.6.2" }, { name = "langgraph-supervisor", specifier = ">=0.0.29" }, { name = "openai", specifier = ">=1.98.0" }, + { name = "opentelemetry-api", specifier = ">=1.36.0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.36.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.36.0" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pydantic-settings", specifier = ">=2.9.1" }, { name = "pypdf", specifier = ">=5.9.0" },