Skip to content

Commit 9c3fae9

Browse files
authored
Add OpAMP agent (#320)
Introduce a basic OpAMP agent in our distro that permits to update the current config at runtime. At the moment only the `logging_level` config is supported to update dynamically the sdk logging level.
1 parent e0f8413 commit 9c3fae9

31 files changed

+4030
-11
lines changed

.github/workflows/ci.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
test:
6666
runs-on: ubuntu-latest
6767
env:
68-
py39: 3.9
68+
py39: "3.9"
6969
py310: "3.10"
7070
py311: "3.11"
7171
py312: "3.12"
@@ -82,4 +82,16 @@ jobs:
8282
python-version: ${{ env[matrix.python-version] }}
8383
architecture: "x64"
8484
- run: pip install -r dev-requirements.txt
85+
- name: run recorded tests with python 3.10+ where urllib3 2.x is supported
86+
run: pip install pytest-vcr
87+
if: ${{ matrix.python-version != 'py39' }}
8588
- run: pytest --with-integration-tests
89+
90+
typecheck:
91+
runs-on: ubuntu-latest
92+
steps:
93+
- uses: actions/checkout@v4
94+
- uses: ./.github/actions/env-install
95+
- run: pip install -r dev-requirements.txt
96+
- run: pip install pyright
97+
- run: pyright

dev-requirements.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pyproject-hooks==1.2.0
112112
# via
113113
# build
114114
# pip-tools
115-
pytest==8.4.0
115+
pytest==8.4.1
116116
# via elastic-opentelemetry (pyproject.toml)
117117
requests==2.32.4
118118
# via
@@ -132,8 +132,10 @@ typing-extensions==4.14.0
132132
# opentelemetry-resourcedetector-gcp
133133
# opentelemetry-sdk
134134
# opentelemetry-semantic-conventions
135-
urllib3==2.4.0
135+
urllib3==2.5.0
136136
# via requests
137+
uuid-utils==0.11.0
138+
# via elastic-opentelemetry (pyproject.toml)
137139
wheel==0.45.1
138140
# via pip-tools
139141
wrapt==1.17.2

opamp-gen-requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Use caution when bumping this version to ensure compatibility with the currently supported protobuf version.
2+
# Pinning this to the oldest grpcio version that supports protobuf 5 helps avoid RuntimeWarning messages
3+
# from the generated protobuf code and ensures continued stability for newer grpcio versions.
4+
grpcio-tools==1.63.2
5+
mypy-protobuf~=3.5.0

pyproject.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ dependencies = [
3838
"opentelemetry-sdk-extension-aws ~= 2.1.0",
3939
"opentelemetry-semantic-conventions == 0.55b1",
4040
"packaging",
41+
"uuid-utils",
4142
]
4243

4344
[project.optional-dependencies]
44-
dev = ["pytest", "pip-tools", "oteltest==0.24.0", "leb128"]
45+
dev = ["pytest", "pip-tools", "oteltest==0.24.0", "leb128", "pytest-vcr ; python_version > '3.9'"]
4546

4647
[project.entry-points.opentelemetry_configurator]
4748
configurator = "elasticotel.distro:ElasticOpenTelemetryConfigurator"
@@ -86,9 +87,26 @@ build-backend = "setuptools.build_meta"
8687
[tool.ruff]
8788
target-version = "py38"
8889
line-length = 120
90+
extend-exclude = [
91+
"*_pb2*.py*",
92+
]
8993

9094
[tool.ruff.lint.isort]
9195
known-third-party = [
9296
"opentelemetry",
9397
]
9498
known-first-party = ["elasticotel"]
99+
100+
[tool.pyright]
101+
typeCheckingMode = "standard"
102+
pythonVersion = "3.9"
103+
104+
include = [
105+
"src/elasticotel",
106+
"src/opentelemetry",
107+
]
108+
109+
exclude = [
110+
"**/__pycache__",
111+
"src/opentelemetry/_opamp/proto",
112+
]

scripts/license_headers_check.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717

1818
if [ $# -eq 0 ]
1919
then
20-
FILES=$(find . \( -name "*.py" -o -name "*.c" -o -name "*.sh" \) -size +1c -not -path "./dist/*" -not -path "./build/*" -not -path "./venv*/*")
20+
FILES=$(git ls-files | grep -e "\.py$" -e "\.c$" -e "\.sh$" | grep -v -e "/proto/" | xargs -r -d'\n' -I{} find {} -size +1c)
2121
else
2222
FILES=$@
2323
fi
2424

2525
LICENSE_HEADER="Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one"
26+
UPSTREAM_LICENSE_HEADER="Copyright The OpenTelemetry Authors"
2627

27-
MISSING=$(grep --files-without-match "$LICENSE_HEADER" ${FILES})
28+
MISSING=$(grep --files-without-match -e "$LICENSE_HEADER" -e "$UPSTREAM_LICENSE_HEADER" ${FILES})
2829

2930
if [ -z "$MISSING" ]
3031
then

scripts/opamp_proto_codegen.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/bin/bash
2+
# Copyright The OpenTelemetry Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
# Regenerate python code from opamp protos in
17+
# https://github.com/open-telemetry/opamp-spec
18+
#
19+
# To use, update OPAMP_SPEC_REPO_BRANCH_OR_COMMIT variable below to a commit hash or
20+
# tag in opentelemtry-proto repo that you want to build off of. Then, just run
21+
# this script to update the proto files. Commit the changes as well as any
22+
# fixes needed in the OTLP exporter.
23+
#
24+
# Optional envars:
25+
# OPAMP_SPEC_REPO_DIR - the path to an existing checkout of the opamp-spec repo
26+
27+
# Pinned commit/branch/tag for the current version used in the opamp python package.
28+
OPAMP_SPEC_REPO_BRANCH_OR_COMMIT="v0.12.0"
29+
30+
set -e
31+
32+
OPAMP_SPEC_REPO_DIR=${OPAMP_SPEC_REPO_DIR:-"/tmp/opamp-spec"}
33+
# root of opentelemetry-python repo
34+
repo_root="$(git rev-parse --show-toplevel)"
35+
venv_dir="/tmp/opamp_proto_codegen_venv"
36+
proto_output_dir="$repo_root/src/opentelemetry/_opamp/proto"
37+
38+
# run on exit even if crash
39+
cleanup() {
40+
echo "Deleting $venv_dir"
41+
rm -rf $venv_dir
42+
}
43+
trap cleanup EXIT
44+
45+
echo "Creating temporary virtualenv at $venv_dir using $(python3 --version)"
46+
python3 -m venv $venv_dir
47+
source $venv_dir/bin/activate
48+
python -m pip install \
49+
-c $repo_root/opamp-gen-requirements.txt \
50+
grpcio-tools mypy-protobuf
51+
echo 'python -m grpc_tools.protoc --version'
52+
python -m grpc_tools.protoc --version
53+
54+
# Clone the proto repo if it doesn't exist
55+
if [ ! -d "$OPAMP_SPEC_REPO_DIR" ]; then
56+
git clone https://github.com/open-telemetry/opamp-spec.git $OPAMP_SPEC_REPO_DIR
57+
fi
58+
59+
# Pull in changes and switch to requested branch
60+
(
61+
cd $OPAMP_SPEC_REPO_DIR
62+
git fetch --all
63+
git checkout $OPAMP_SPEC_REPO_BRANCH_OR_COMMIT
64+
# pull if OPAMP_SPEC_BRANCH_OR_COMMIT is not a detached head
65+
git symbolic-ref -q HEAD && git pull --ff-only || true
66+
)
67+
68+
cd $proto_output_dir
69+
70+
# clean up old generated code
71+
find . -regex ".*_pb2.*\.pyi?" -exec rm {} +
72+
73+
# generate proto code for all protos
74+
all_protos=$(find $OPAMP_SPEC_REPO_DIR/ -name "*.proto")
75+
python -m grpc_tools.protoc \
76+
-I $OPAMP_SPEC_REPO_DIR/proto \
77+
--python_out=. \
78+
--mypy_out=. \
79+
$all_protos
80+
81+
sed -i -e 's/import anyvalue_pb2 as anyvalue__pb2/from . import anyvalue_pb2 as anyvalue__pb2/' opamp_pb2.py

src/elasticotel/distro/__init__.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import logging
1818
import os
19+
from urllib.parse import urlparse, urlunparse
1920

2021
from opentelemetry.environment_variables import (
2122
OTEL_LOGS_EXPORTER,
@@ -35,17 +36,56 @@
3536
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE,
3637
OTEL_EXPORTER_OTLP_PROTOCOL,
3738
)
39+
from opentelemetry.sdk.resources import OTELResourceDetector
3840
from opentelemetry.util._importlib_metadata import EntryPoint
41+
from opentelemetry._opamp.agent import OpAMPAgent
42+
from opentelemetry._opamp.client import OpAMPClient
43+
from opentelemetry._opamp.proto import opamp_pb2 as opamp_pb2
3944

40-
from elasticotel.distro.environment_variables import ELASTIC_OTEL_SYSTEM_METRICS_ENABLED
45+
from elasticotel.distro.environment_variables import ELASTIC_OTEL_OPAMP_ENDPOINT, ELASTIC_OTEL_SYSTEM_METRICS_ENABLED
4146
from elasticotel.distro.resource_detectors import get_cloud_resource_detectors
47+
from elasticotel.distro.config import opamp_handler
4248

4349

4450
logger = logging.getLogger(__name__)
4551

4652

4753
class ElasticOpenTelemetryConfigurator(_OTelSDKConfigurator):
48-
pass
54+
def _configure(self, **kwargs):
55+
super()._configure(**kwargs)
56+
57+
enable_opamp = False
58+
endpoint = os.environ.get(ELASTIC_OTEL_OPAMP_ENDPOINT)
59+
if endpoint:
60+
parsed = urlparse(endpoint)
61+
enable_opamp = parsed.scheme in ("http", "https") and parsed.netloc
62+
if enable_opamp:
63+
if not parsed.path:
64+
parsed = parsed._replace(path="/v1/opamp")
65+
66+
endpoint_url = urlunparse(parsed)
67+
# this is not great but we don't have the calculated resource attributes around
68+
resource = OTELResourceDetector().detect()
69+
agent_identifying_attributes = {
70+
"service.name": resource.attributes.get("service.name"),
71+
}
72+
if deployment_environment_name := resource.attributes.get(
73+
"deployment.environment.name", resource.attributes.get("deployment.environment")
74+
):
75+
agent_identifying_attributes["deployment.environment.name"] = deployment_environment_name
76+
77+
opamp_client = OpAMPClient(
78+
endpoint=endpoint_url,
79+
agent_identifying_attributes=agent_identifying_attributes,
80+
)
81+
opamp_agent = OpAMPAgent(
82+
interval=30,
83+
message_handler=opamp_handler,
84+
client=opamp_client,
85+
)
86+
opamp_agent.start()
87+
else:
88+
logger.warning("Found invalid value for OpAMP endpoint")
4989

5090

5191
class ElasticOpenTelemetryDistro(BaseDistro):
@@ -63,7 +103,7 @@ def load_instrumentor(self, entry_point: EntryPoint, **kwargs):
63103
instrumentor_kwargs["config"] = {
64104
k: v for k, v in SYSTEM_METRICS_DEFAULT_CONFIG.items() if k.startswith("process.runtime")
65105
}
66-
instrumentor_class(**instrumentor_kwargs).instrument(**kwargs)
106+
instrumentor_class(**instrumentor_kwargs).instrument(**kwargs) # type: ignore[reportCallIssue]
67107

68108
def _configure(self, **kwargs):
69109
os.environ.setdefault(OTEL_TRACES_EXPORTER, "otlp")

src/elasticotel/distro/config.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
# or more contributor license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright
4+
# ownership. Elasticsearch B.V. licenses this file to you under
5+
# the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import logging
18+
19+
from opentelemetry._opamp import messages
20+
from opentelemetry._opamp.client import OpAMPClient
21+
from opentelemetry._opamp.proto import opamp_pb2 as opamp_pb2
22+
23+
24+
logger = logging.getLogger(__name__)
25+
26+
_LOG_LEVELS_MAP = {
27+
"trace": 5,
28+
"debug": logging.DEBUG,
29+
"info": logging.INFO,
30+
"warn": logging.WARNING,
31+
"error": logging.ERROR,
32+
"fatal": logging.CRITICAL,
33+
"off": 1000,
34+
}
35+
36+
37+
def opamp_handler(client: OpAMPClient, message: opamp_pb2.ServerToAgent):
38+
if not message.remote_config:
39+
return
40+
41+
for config_filename, config in messages._decode_remote_config(message.remote_config):
42+
# we don't have standardized config values so limit to configs coming from our backend
43+
if config_filename == "elastic":
44+
logger.debug("Config %s: %s", config_filename, config)
45+
# when config option has default value you don't get it so need to handle the default
46+
config_logging_level = config.get("logging_level")
47+
if config_logging_level is not None:
48+
logging_level = _LOG_LEVELS_MAP.get(config_logging_level) # type: ignore[reportArgumentType]
49+
else:
50+
logging_level = logging.INFO
51+
52+
if logging_level is None:
53+
logger.warning("Logging level not handled: %s", config_logging_level)
54+
else:
55+
# update upstream and distro logging levels
56+
logging.getLogger("opentelemetry").setLevel(logging_level)
57+
logging.getLogger("elasticotel").setLevel(logging_level)

src/elasticotel/distro/environment_variables.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@
2222
2323
**Default value:** ``false``
2424
"""
25+
26+
ELASTIC_OTEL_OPAMP_ENDPOINT = "ELASTIC_OTEL_OPAMP_ENDPOINT"
27+
"""
28+
.. envvar:: ELASTIC_OTEL_OPAMP_ENDPOINT
29+
30+
OpAMP Endpoint URL.
31+
32+
**Default value:** ``not set``
33+
"""

src/opentelemetry/_opamp/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# opamp
2+
3+
opamp is an OpAMP protocol implementation.
4+
5+
Implementation tries to be agnostic to the transport libraries and protocols used but since it's only HTTP for now that
6+
may be achieved once more transport implementation appears.
7+

0 commit comments

Comments
 (0)