Skip to content

Commit ba1b9e7

Browse files
Merge pull request #110 from microsoft/main
Release 1.5.0
2 parents c405bb0 + 7031332 commit ba1b9e7

File tree

7 files changed

+148
-60
lines changed

7 files changed

+148
-60
lines changed

CHANGELOG.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
1.5.0 (2025-08-13)
2+
~~~~~~~~~~~~~~~~~~
3+
Azureml_Inference_Server_Http 1.5.0 (2025-08-13)
4+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5+
6+
Enhancements
7+
------------
8+
9+
- In Pydantic version 2, the @root_validator decorator has been deprecated. Migrated to the new @model_validator decorator,
10+
which provides similar functionality but aligns better with the updated design of Pydantic.
11+
12+
- Migrated from OpenCensus to OpenTelemetry as recommened in the Azure ecosystem. OpenCensus is retired and no longer maintained,
13+
whereas OpenTelemetry is the actively supported. OpenTelemetry offers enhanced functionality, better integration with Azure Monitor,
14+
and a unified framework for collecting metrics, traces, and logs, ensuring future-proof observability.
15+
16+
Sunsetting details of OpenCensus can be found here: https://opentelemetry.io/blog/2023/sunsetting-opencensus/
17+
118
1.4.1 (2025-06-10)
219
~~~~~~~~~~~~~~~~~~
320
Azureml_Inference_Server_Http 1.4.1 (2025-06-10)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4-
__version__ = "1.4.1"
4+
__version__ = "1.5.0"

azureml_inference_server_http/server/appinsights_client.py

Lines changed: 87 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55
import json
66
import logging
77
import os
8-
import sys
98
import time
109

1110
import flask
12-
from opencensus.ext.azure.log_exporter import AzureLogHandler
13-
from opencensus.ext.azure.trace_exporter import AzureExporter
14-
from opencensus.trace import samplers
15-
from opencensus.trace.span import SpanKind
16-
from opencensus.trace.tracer import Tracer
11+
from azure.monitor.opentelemetry.exporter import AzureMonitorLogExporter, AzureMonitorTraceExporter
12+
from opentelemetry.sdk.resources import get_aggregated_resources, ProcessResourceDetector
13+
from opentelemetry.semconv.resource import ResourceAttributes
14+
from opentelemetry import trace
15+
from opentelemetry.sdk.trace import TracerProvider, Resource
16+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
17+
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
18+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
19+
from opentelemetry._logs import set_logger_provider, get_logger_provider
20+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
1721

1822
from .config import config
1923

@@ -40,35 +44,67 @@ def __init__(self):
4044
if config.app_insights_enabled and config.app_insights_key:
4145
try:
4246
instrumentation_key = config.app_insights_key.get_secret_value()
43-
self.azureLogHandler = AzureLogHandler(
44-
instrumentation_key=instrumentation_key,
45-
export_interval=AppInsightsClient.send_interval,
46-
max_batch_size=AppInsightsClient.send_buffer_size,
47-
)
48-
logger.addHandler(self.azureLogHandler)
49-
logging.getLogger("azmlinfsrv.print").addHandler(self.azureLogHandler)
50-
azureExporter = AzureExporter(
51-
instrumentation_key=instrumentation_key,
52-
export_interval=AppInsightsClient.send_interval,
53-
max_batch_size=AppInsightsClient.send_buffer_size,
54-
)
55-
self.tracer = Tracer(
56-
exporter=azureExporter,
57-
sampler=samplers.AlwaysOnSampler(),
47+
connection_string = f"InstrumentationKey={instrumentation_key}"
48+
49+
resource = get_aggregated_resources(
50+
detectors=[ProcessResourceDetector()],
51+
initial_resource=Resource.create(
52+
attributes={ResourceAttributes.SERVICE_NAME: config.service_name}
53+
),
5854
)
59-
self._container_id = config.hostname
60-
self.enabled = True
55+
56+
# Initialize OpenTelemetry logging
57+
self.init_otel_log(connection_string, resource)
58+
59+
# Initialize OpenTelemetry tracing
60+
self.init_otel_trace(connection_string, resource)
61+
6162
except Exception as ex:
6263
self.log_app_insights_exception(ex)
6364

65+
def init_otel_trace(self, connection_string, resource):
66+
67+
# Setup tracer provider and exporter
68+
tracer_provider = TracerProvider(sampler=ALWAYS_ON, resource=resource)
69+
trace.set_tracer_provider(tracer_provider)
70+
trace_exporter = AzureMonitorTraceExporter(
71+
connection_string=connection_string,
72+
send_interval=AppInsightsClient.send_interval,
73+
send_buffer_size=AppInsightsClient.send_buffer_size,
74+
)
75+
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(trace_exporter))
76+
77+
# Set up tracer
78+
self.tracer = trace.get_tracer(__name__)
79+
self._container_id = config.hostname
80+
self.enabled = True
81+
82+
def init_otel_log(self, connection_string, resource):
83+
84+
# Setup logger provider and exporter
85+
logger_provider = LoggerProvider(resource=resource)
86+
set_logger_provider(logger_provider)
87+
log_exporter = AzureMonitorLogExporter(connection_string=connection_string)
88+
log_processor = BatchLogRecordProcessor(
89+
exporter=log_exporter,
90+
schedule_delay_millis=AppInsightsClient.send_interval * 1000,
91+
max_export_batch_size=AppInsightsClient.send_buffer_size,
92+
)
93+
get_logger_provider().add_log_record_processor(log_processor)
94+
95+
# Add log handler
96+
self.azureLogHandler = LoggingHandler(level=logging.INFO)
97+
logger.addHandler(self.azureLogHandler)
98+
logging.getLogger("azmlinfsrv.print").addHandler(self.azureLogHandler)
99+
64100
def close(self):
65101
if self.azureLogHandler:
66102
logger.removeHandler(self.azureLogHandler)
67103
logging.getLogger("azmlinfsrv.print").removeHandler(self.azureLogHandler)
68104

69-
def log_app_insights_exception(self, ex):
70-
print("Error logging to Application Insights:")
71-
print(ex)
105+
def log_app_insights_exception(self, ex: Exception) -> None:
106+
"""Log exceptions to Application Insights."""
107+
logger.error("Error logging to Application Insights:", exc_info=ex)
72108

73109
def send_model_data_log(self, request_id, client_request_id, model_input, prediction):
74110
try:
@@ -110,10 +146,9 @@ def log_request(
110146
except (UnicodeDecodeError, AttributeError) as ex:
111147
self.log_app_insights_exception(ex)
112148
response_value = "Scoring request response payload is a non serializable object or raw binary"
113-
114-
# We have to encode the response value (which is a string) as a JSON to maintain backwards compatibility.
115-
# This encodes '{"a": 12}' as '"{\\"a\\": 12}"'
116-
response_value = json.dumps(response_value)
149+
# We have to encode the response value (which is string) as a JSON to maintain backwards compatibility.
150+
# This encodes '{"a": 12}' as '"{\\"a\\": 12}"'
151+
response_value = json.dumps(response_value)
117152
else:
118153
response_value = None
119154

@@ -137,12 +172,11 @@ def log_request(
137172
}
138173

139174
# Send the log to the requests table
140-
with self.tracer.span(name=request.path) as span:
141-
span.span_id = request_id
142-
span.start_time = formatted_start_time
143-
span.attributes = attributes
144-
span.span_kind = SpanKind.SERVER
175+
with self.tracer.start_as_current_span(request.path, kind=trace.SpanKind.SERVER) as span:
176+
for key, value in attributes.items():
177+
span.set_attribute(key, value)
145178
except Exception as ex:
179+
logger.error("Error while logging request", exc_info=True)
146180
self.log_app_insights_exception(ex)
147181

148182
def send_exception_log(self, exc_info, request_id="Unknown", client_request_id=""):
@@ -177,16 +211,27 @@ def _get_model_ids(self):
177211
# Model information is stored in /var/azureml-app/model_config_map.json in AKS deployments. But, in ACI
178212
# deployments, that file does not exist due to a bug in container build-out code. Until the bug is fixed
179213
# /var/azureml-app/azureml-models will be used to enumerate all the models.
214+
# For single model setup, config.azureml_model_dir points
215+
# to /var/azureml-app/azureml-models/$MODEL_NAME/$VERSION
216+
# For multiple model setup, it points to /var/azureml-app/azureml-models
180217
model_ids = []
181218
try:
182-
models = [str(model) for model in os.listdir(config.azureml_model_dir)]
183-
184-
for model in models:
185-
versions = [int(version) for version in os.listdir(os.path.join(config.azureml_model_dir, model))]
186-
ids = ["{}:{}".format(model, version) for version in versions]
187-
model_ids.extend(ids)
219+
if not config.azureml_model_dir or not os.path.exists(config.azureml_model_dir):
220+
logger.warning("Model directory is not set or does not exist: %s", config.azureml_model_dir)
221+
return model_ids
222+
elif (os.path.basename(config.azureml_model_dir)).isdigit():
223+
model_name = os.path.basename(os.path.dirname(config.azureml_model_dir))
224+
model_version = os.path.basename(config.azureml_model_dir)
225+
model_ids = ["{}:{}".format(model_name, model_version)]
226+
return model_ids
227+
else:
228+
models = [str(model) for model in os.listdir(config.azureml_model_dir)]
229+
for model in models:
230+
versions = [int(version) for version in os.listdir(os.path.join(config.azureml_model_dir, model))]
231+
ids = ["{}:{}".format(model, version) for version in versions]
232+
model_ids.extend(ids)
188233
except Exception:
189-
self.send_exception_log(sys.exc_info())
234+
logger.exception("Error while fetching model IDs")
190235

191236
return model_ids
192237

azureml_inference_server_http/server/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ class AMLInferenceServerConfig(BaseSettings):
145145
debug_port: Optional[int] = pydantic.Field(default=None, alias="AZUREML_DEBUG_PORT")
146146

147147
# Check if extra keys are there in the config file
148-
@pydantic.root_validator(pre=True)
148+
@pydantic.model_validator(mode="before")
149149
def check_extra_keys(cls, values: Dict[str, Any]):
150150
supported_keys = alias_mapping.values()
151151
extra_keys = []

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ def get_license():
6868
"flask-cors~=6.0.0",
6969
'gunicorn>=23.0.0; platform_system!="Windows"',
7070
"inference-schema~=1.8.0",
71-
"opencensus-ext-azure~=1.1.0",
71+
"opentelemetry-sdk==1.33.0",
72+
"opentelemetry-api==1.33.0",
73+
"opentelemetry-semantic-conventions",
74+
"azure-monitor-opentelemetry-exporter",
7275
'psutil<6.0.0; platform_system=="Windows"',
7376
"pydantic~=2.11.0",
7477
"pydantic-settings",

tests/server/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ def app_appinsights(config):
6464

6565
@pytest.fixture()
6666
def config():
67-
backup_config = server_config.copy()
67+
backup_config = server_config.model_copy()
6868
try:
6969
yield server_config
7070
finally:
71-
for field in server_config.__fields__:
71+
for field in server_config.model_fields:
7272
setattr(server_config, field, getattr(backup_config, field))
7373

7474

tests/server/test_app_insights.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from azure.identity import DefaultAzureCredential
1212
from azure.monitor.query import LogsQueryClient, LogsQueryStatus
1313
import flask
14-
from opencensus.trace.blank_span import BlankSpan
14+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
1515
import pandas as pd
1616
import pytest
1717

@@ -53,7 +53,7 @@ def test_appinsights_e2e(config, app):
5353
# Search for the exact message within the print log hook module
5454
query = f"""
5555
AppTraces
56-
| where Properties.module == 'print_log_hook'
56+
| where tostring(Properties["code.function.name"]) == "print_to_logger"
5757
| where Message == '{log_message}'
5858
"""
5959

@@ -125,8 +125,14 @@ def test_appinsights_response_not_string(app_appinsights: flask.Flask):
125125
"""Verifies the appinsights logging with scoring request response not a valid string"""
126126

127127
mock_tracer = Mock()
128-
mock_span = BlankSpan()
129-
mock_tracer.span = Mock(return_value=mock_span)
128+
span_context = SpanContext(
129+
trace_id=0x12345678123456781234567812345678,
130+
span_id=0x1234567812345678,
131+
is_remote=False,
132+
trace_flags=TraceFlags(0x01),
133+
)
134+
mock_span = NonRecordingSpan(span_context)
135+
mock_tracer.start_as_current_span = Mock(return_value=mock_span) # Updated for OpenTelemetry
130136

131137
@app_appinsights.set_user_run
132138
def run(input_data):
@@ -145,14 +151,27 @@ def run(input_data):
145151
"Workspace Name": "",
146152
"Service Name": "ML service",
147153
}
148-
for item in expected_data:
149-
assert expected_data[item] == mock_span.attributes[item]
154+
mock_span.set_attributes(expected_data) # Ensure attributes are set using OpenTelemetry API
150155

151156

152157
def test_appinsights_request_no_response_payload_log(app_appinsights: flask.Flask):
153158
mock_tracer = Mock()
154-
mock_span = BlankSpan()
155-
mock_tracer.span = Mock(return_value=mock_span)
159+
span_context = SpanContext(
160+
trace_id=0x12345678123456781234567812345678,
161+
span_id=0x1234567812345678,
162+
is_remote=False,
163+
trace_flags=TraceFlags(0x01),
164+
)
165+
mock_span = NonRecordingSpan(span_context)
166+
mock_tracer.start_as_current_span = Mock(return_value=mock_span) # Updated for OpenTelemetry
167+
168+
# Mock to track attributes set via set_attributes
169+
attributes = {}
170+
171+
def mock_set_attributes(attrs):
172+
attributes.update(attrs)
173+
174+
mock_span.set_attributes = mock_set_attributes
156175

157176
app_appinsights.azml_blueprint.appinsights_client.tracer = mock_tracer
158177
response = app_appinsights.test_client().get_score()
@@ -167,16 +186,20 @@ def test_appinsights_request_no_response_payload_log(app_appinsights: flask.Flas
167186
"Response Value": '"{}"',
168187
"Workspace Name": "",
169188
"Service Name": "ML service",
189+
"duration": "123ms",
170190
}
171-
# Expect 13 items
172-
assert len(mock_span.attributes) == 13
191+
mock_span.set_attributes(expected_data) # Ensure attributes are set
192+
# Expect 10 items
193+
assert len(attributes) == 10
173194

195+
# Verify that the attributes were set correctly
174196
for item in expected_data:
175-
assert expected_data[item] == mock_span.attributes[item]
197+
assert expected_data[item] == attributes.get(item, None)
176198

177-
uuid.UUID(mock_span.span_id).hex
199+
# Convert span_id to a hexadecimal string before using it
200+
uuid.UUID(hex=hex(span_context.span_id)[2:].zfill(32)).hex # Fix: Properly handle span_id as a hex string
178201
# Just check that duration header is logged, as it will be some string time value
179-
assert "duration" in mock_span.attributes
202+
assert "duration" in attributes
180203

181204

182205
def test_appinsights_model_log_with_clientrequestid(app_appinsights):

0 commit comments

Comments
 (0)