Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sample:
tests:
- name: delegated-web-research
turns:
- input: Research how LangGraph checkpoints support multi-turn agents using delegated research. Include the exact primary-source URL https://docs.langchain.com/oss/python/langgraph/overview in both the saved report and your final reply. Save the cited report to /report-v1.md and give me its download link.
- input: Research how LangGraph checkpoints support multi-turn agents using delegated research. Include the exact primary-source URL https://docs.langchain.com/oss/python/langgraph/overview in both the saved report and your final reply. Save the cited report to /report-v1.md and give me its Blob URL and SHA256 for an Entra-authenticated download.
assertions:
- source: assistant_text
type: contains
Expand All @@ -18,10 +18,18 @@ tests:
- source: raw
type: regex
value: '"name"\s*:\s*"publish_report"'
- source: raw
type: contains
value: Microsoft Entra ID
- source: raw
type: regex
value: '(?i)sig='
min_matches: 0
max_matches: 0
- source: assistant_text
type: regex
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v1\.md\?[^\s]*sig='
- input: Read the saved /report-v1.md and wait for its contents. In a separate tool round, use write_file with that full unchanged report text as content to create /report-v2.md; do not create an empty file. After the copy succeeds, use edit_file to add a section headed 'Recovery Limitations' only to /report-v2.md while preserving its findings and citations. Do not repeat the research or modify /report-v1.md. Call publish_report with report_name='report-v2.md'. In your final reply, mention 'Recovery Limitations' and give me the download link returned by that tool, not a link constructed from the earlier URL.
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v1\.md'
- input: Read the saved /report-v1.md and wait for its contents. In a separate tool round, use write_file with that full unchanged report text as content to create /report-v2.md; do not create an empty file. After the copy succeeds, use edit_file to add a section headed 'Recovery Limitations' only to /report-v2.md while preserving its findings and citations. Do not repeat the research or modify /report-v1.md. Call publish_report with report_name='report-v2.md'. In your final reply, mention 'Recovery Limitations' and give me its unsigned Blob URL and SHA256 returned by that tool. Explain that downloading requires my own Microsoft Entra identity and Blob read permission. Do not construct a URL or issue a SAS.
assertions:
- source: assistant_text
type: contains
Expand All @@ -32,10 +40,18 @@ tests:
- source: raw
type: regex
value: '"name"\s*:\s*"publish_report"'
- source: raw
type: contains
value: Microsoft Entra ID
- source: raw
type: regex
value: '(?i)sig='
min_matches: 0
max_matches: 0
- source: assistant_text
type: regex
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v2\.md\?[^\s]*sig='
- input: Read both /report-v1.md and /report-v2.md using read_file, and confirm that only report-v2.md includes the added 'Recovery Limitations' section. Return the two existing download links labeled report-v1.md and report-v2.md. Do not edit the files or publish them again.
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v2\.md'
- input: Read both /report-v1.md and /report-v2.md using read_file, and confirm that only report-v2.md includes the added 'Recovery Limitations' section. Return the two existing Blob URLs and SHA256 digests labeled report-v1.md and report-v2.md. Do not edit the files or publish them again.
assertions:
- source: raw
type: regex
Expand All @@ -48,7 +64,22 @@ tests:
value: report-v2.md
- source: assistant_text
type: regex
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v1\.md\?[^\s]*sig='
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v1\.md'
- source: assistant_text
type: regex
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v2\.md\?[^\s]*sig='
value: 'https://[a-z0-9]+\.blob\.core\.windows\.net/[^\s]+/report-v2\.md'
assertions:
- source: console_log
type: contains
value: Starting Deep Agents host with Entra-authenticated report downloads
- source: console_log
type: contains
value: Published report report-v1.md
- source: console_log
type: contains
value: Published report report-v2.md
- source: console_log
type: regex
value: '(?i)(sig=|authorization: bearer)'
min_matches: 0
max_matches: 0
200 changes: 200 additions & 0 deletions .github/scripts/test/test_custom_websocket_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Regressions for telemetry startup in custom, non-AgentServer WebSocket apps."""

from __future__ import annotations

import ast
import importlib.util
import logging
import os
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

ROOT = Path(__file__).resolve().parents[3]
RUNTIMES = (
("livekit-server", "livekit-server"),
("pipecat-webrtc", "pipecat-ws-webrtc"),
("pipecat-ws-server", "pipecat-ws-server"),
)


def runtime_path(sample: str, project: str) -> Path:
return (
ROOT
/ "samples/python/hosted-agents/bring-your-own/invocations_ws"
/ sample
/ "src"
/ project
)


class CustomWebSocketTelemetryTests(unittest.TestCase):
def test_default_setup_is_idempotent_and_preserves_safe_log_settings(self) -> None:
for sample, project in RUNTIMES:
for connection_string in (None, "test-connection"):
with self.subTest(sample=sample, connection=connection_string):
distro = types.ModuleType("microsoft.opentelemetry")
configure = MagicMock()
distro.use_microsoft_opentelemetry = configure
loguru = types.ModuleType("loguru")
loguru.logger = MagicMock()
modules = {"microsoft.opentelemetry": distro, "loguru": loguru}
spec = importlib.util.spec_from_file_location(
"custom_telemetry_under_test",
runtime_path(sample, project) / "telemetry.py",
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
with (
patch.dict(sys.modules, modules),
patch.dict(os.environ, {}, clear=True),
patch.object(logging, "basicConfig") as configure_logging,
):
if connection_string:
os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"] = (
connection_string
)
spec.loader.exec_module(module)
with patch.object(module, "find_spec", return_value=None):
module.configure_telemetry()
module.configure_telemetry()
configure_logging.assert_called_once_with(level=logging.INFO)
configure.assert_called_once_with(
enable_azure_monitor=bool(connection_string),
azure_monitor_connection_string=connection_string,
enable_sensitive_data=False,
instrumentation_options={"openai": {"enabled": False}},
)
loguru.logger.remove.assert_called_once()
sink = loguru.logger.add.call_args
self.assertIsInstance(sink.args[0], logging.Handler)
self.assertEqual(sink.kwargs["level"], "INFO")
self.assertFalse(sink.kwargs["diagnose"])
self.assertFalse(sink.kwargs["backtrace"])

def test_loguru_sink_forwards_records_to_standard_logging(self) -> None:
for sample, project in RUNTIMES:
with self.subTest(sample=sample):
distro = types.ModuleType("microsoft.opentelemetry")
distro.use_microsoft_opentelemetry = MagicMock()
loguru = types.ModuleType("loguru")
loguru.logger = MagicMock()
spec = importlib.util.spec_from_file_location(
"custom_telemetry_under_test",
runtime_path(sample, project) / "telemetry.py",
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
with patch.dict(
sys.modules, {"microsoft.opentelemetry": distro, "loguru": loguru}
):
spec.loader.exec_module(module)
record = logging.makeLogRecord(
{
"name": "custom.websocket",
"msg": "Connection accepted",
"levelno": logging.INFO,
}
)
with patch.object(logging, "getLogger") as get_logger:
module.ForwardToLogging().emit(record)
get_logger.assert_called_once_with("custom.websocket")
get_logger.return_value.handle.assert_called_once_with(record)

def test_each_server_initializes_telemetry_before_bot_import_or_app_creation(
self,
) -> None:
for sample, project in RUNTIMES:
with self.subTest(sample=sample):
tree = ast.parse(
(runtime_path(sample, project) / "server.py").read_text(
encoding="utf-8-sig"
)
)
setup = [
node.lineno
for node in tree.body
if isinstance(node, ast.Expr)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "configure_telemetry"
]
apps = [
node.lineno
for node in tree.body
if isinstance(node, ast.Assign)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "FastAPI"
]
self.assertEqual(len(setup), 1)
self.assertTrue(apps and setup[0] < min(apps))

def test_livekit_worker_initializes_each_process_and_does_not_log_payloads(
self,
) -> None:
tree = ast.parse(
(runtime_path("livekit-server", "livekit-server") / "agent.py").read_text(
encoding="utf-8-sig"
)
)
entrypoint = next(
node
for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name == "entrypoint"
)
calls = [node for node in ast.walk(entrypoint) if isinstance(node, ast.Call)]
self.assertTrue(
any(
isinstance(node.func, ast.Name)
and node.func.id == "configure_telemetry"
for node in calls
)
)
logs = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "info"
]
self.assertFalse(
any(
any(
isinstance(argument, ast.Name) and argument.id == "payload"
for argument in node.args
)
for node in logs
)
)

def test_pipecat_info_logs_do_not_include_model_supplied_reasons(self) -> None:
tree = ast.parse(
(
runtime_path("pipecat-ws-server", "pipecat-ws-server")
/ "bot_websocket_server.py"
).read_text(encoding="utf-8-sig")
)
logs = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "info"
]
self.assertFalse(
any(
any(
isinstance(item, ast.Name) and item.id == "reason"
for argument in node.args
for item in ast.walk(argument)
)
for node in logs
)
)


if __name__ == "__main__":
unittest.main()
7 changes: 7 additions & 0 deletions samples/python/hosted-agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ submitting changes.

## Runtime logging

Keep the standard host's default telemetry enabled. Configure INFO logging for
startup diagnostics before host creation and reuse the host's instrumentation
and exporters; do not configure a second provider or framework instrumentor.
Message/tool content recording is distinct from logging and remains opt-in for
sensitive data. Document the Application Insights or OTLP export configuration
and verify destination ingestion, not only console output.

Use the standard `logging` module for diagnostics emitted by a running hosted
agent. Prefer `logger.info()`, `logger.warning()`, and `logger.exception()` over
`print()` when output must be observable while the long-running process remains
Expand Down
8 changes: 8 additions & 0 deletions samples/python/hosted-agents/README-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ lifecycle stays consistent across samples. Give each its own `##` heading. Commo
Delete this comment and add the real sections your sample needs.
-->

## Telemetry and logging

The standard host enables console logging and OpenTelemetry by default. Connect
Application Insights to the Foundry project for hosted export; configure
`APPLICATIONINSIGHTS_CONNECTION_STRING` or an OTLP destination for local export.
Verify logs and traces in the destination. Keep sensitive message/tool content
recording opt-in and reuse the host's instrumentation and exporters.

## Troubleshooting

> Delete if not needed. List errors specific to this sample and their fixes.
Expand Down
27 changes: 26 additions & 1 deletion samples/python/hosted-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,32 @@

Samples for building, deploying, and managing hosted agents on [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents). Each sample is a starter template — fork it, change the system prompt and tools, deploy with `azd up`.

> **Every sample includes Application Insights and OpenTelemetry tracing out of the box.** You get production-ready logging, distributed traces, and metrics from the first sample you run.
> **Hosted Agent Server samples enable logging and OpenTelemetry by default.** Export requires a configured Application Insights connection or OTLP destination; local console logging does not prove remote telemetry ingestion.

## Telemetry and logging

The standard Python/C# Agent Server hosts configure console logging and
OpenTelemetry by default. Keep that default enabled when adapting a sample;
Python startup diagnostics emitted before host construction should initialize
`logging` at INFO. Let the host own instrumentation/export rather than also
configuring another provider or instrumenting the same framework twice.

For Application Insights export, connect the resource to the Foundry project
and verify the injected `APPLICATIONINSIGHTS_CONNECTION_STRING`. For local
runs, provide your own connection string or an OTLP destination. Check actual
logs/spans in the destination; an absent environment variable, a warning, or a
successful model response is not evidence that remote telemetry was exported.
Protocol-only, externally hosted, and framework-specific samples may configure
observability explicitly; inspect their documented entry point instead of
requiring one universal initialization call.

Telemetry enabled does not mean sensitive content must be recorded. Keep
prompts, report bodies, tool payloads, secrets, bearer tokens, and SAS URLs out
of default logs. Message-content capture is a separate opt-in setting; do not
turn `AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED` or
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` on solely to enable logging.
Tests and offline replay utilities may suppress telemetry deliberately; do not
copy those opt-outs into production entry points.

### Quickstart

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,22 @@ azd deploy livekit-server-azd
- [LiveKit Azure TTS plugin](https://docs.livekit.io/agents/models/tts/azure/)
- [LiveKit Azure OpenAI LLM plugin](https://docs.livekit.io/agents/models/llm/azure-openai/)
- Inspiration: [`livekit-examples/agent-deployment#21`](https://github.com/livekit-examples/agent-deployment/pull/21) (Bedrock AgentCore deployment)


## Telemetry and logging

The custom server enables INFO logging and OpenTelemetry at startup. Unlike
Agent Server-based samples, this runtime configures its own provider through
`microsoft-opentelemetry`; `telemetry.py` is idempotent within each process.
Loguru messages are forwarded into standard logging so console and exported
application logs use the same pipeline. DEBUG/TRACE logs and sensitive framework
message capture are not enabled by default.

Connect Application Insights to the Foundry project for hosted export, or set
`APPLICATIONINSIGHTS_CONNECTION_STRING` locally. The exporter is enabled when
that connection string exists; OTLP configuration is also supported by the distro.
Verify destination ingestion separately from console output. Never log API keys,
bearer tokens, signaling payloads, or full user transcripts.

The signaling server and spawned agent worker configure telemetry independently;
each process owns its provider. Voice event logs record event types, not payloads.
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ LIVEKIT_API_SECRET=

# --- FastAPI signaling server -----------------------------------------------
# PORT=8091

# Optional local telemetry export; hosted Foundry supplies this when configured.
APPLICATIONINSIGHTS_CONNECTION_STRING=
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ async def transfer_to_greeter(self, context: RunContext):


async def entrypoint(ctx: agents.JobContext) -> None:
from telemetry import configure_telemetry

configure_telemetry()
await ctx.connect()

session = AgentSession(
Expand Down Expand Up @@ -242,7 +245,7 @@ def _wire_session_events(session: AgentSession, room: rtc.Room) -> None:

def publish(payload: dict) -> None:
"""Log locally + ship the event to the browser as JSON."""
logger.info("event: %s", payload)
logger.info("Voice event: %s", payload.get("type"))
data = json.dumps(payload).encode("utf-8")

async def _send() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
description = "A Microsoft Foundry hosted agent using the Invocations WebSocket protocol."
requires-python = ">=3.12"
dependencies = [
"microsoft-opentelemetry==1.3.8",
"debugpy",
"fastapi>=0.115.0",
"livekit-agents>=1.0.0,<2",
Expand Down
Loading
Loading