usefluency-workflow-telemetry 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluency_workflow_telemetry/__init__.py +6 -0
- fluency_workflow_telemetry/configuration.py +151 -0
- fluency_workflow_telemetry/docs/LICENSE +34 -0
- fluency_workflow_telemetry/docs/README.md +36 -0
- fluency_workflow_telemetry/docs/SDK.md +248 -0
- fluency_workflow_telemetry/docs/examples/README.md +92 -0
- fluency_workflow_telemetry/docs/examples/agent-events.py +44 -0
- fluency_workflow_telemetry/docs/examples/agent-events.ts +31 -0
- fluency_workflow_telemetry/docs/examples/capture-context.py +41 -0
- fluency_workflow_telemetry/docs/examples/capture-context.ts +47 -0
- fluency_workflow_telemetry/docs/examples/collector.yaml +9 -0
- fluency_workflow_telemetry/docs/examples/environment.example +2 -0
- fluency_workflow_telemetry/docs/examples/existing-provider.py +35 -0
- fluency_workflow_telemetry/docs/examples/existing-provider.ts +28 -0
- fluency_workflow_telemetry/docs/examples/langchain-agent.py +37 -0
- fluency_workflow_telemetry/docs/examples/openai-agent.py +35 -0
- fluency_workflow_telemetry/docs/examples/openai-client.py +43 -0
- fluency_workflow_telemetry/docs/examples/pydantic-agent.py +40 -0
- fluency_workflow_telemetry/docs/examples/rpa.py +20 -0
- fluency_workflow_telemetry/docs/examples/rpa.ts +25 -0
- fluency_workflow_telemetry/docs/examples/vercel-agent.ts +65 -0
- fluency_workflow_telemetry/docs/examples/workflow.py +19 -0
- fluency_workflow_telemetry/docs/examples/workflow.ts +20 -0
- fluency_workflow_telemetry/events.py +59 -0
- fluency_workflow_telemetry/py.typed +0 -0
- usefluency_workflow_telemetry-0.1.1.dist-info/METADATA +49 -0
- usefluency_workflow_telemetry-0.1.1.dist-info/RECORD +30 -0
- usefluency_workflow_telemetry-0.1.1.dist-info/WHEEL +5 -0
- usefluency_workflow_telemetry-0.1.1.dist-info/licenses/LICENSE +34 -0
- usefluency_workflow_telemetry-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AgentEvents, configure } from '@usefluency/workflow-telemetry';
|
|
2
|
+
|
|
3
|
+
const telemetry = configure({ serviceName: 'invoice-agent' });
|
|
4
|
+
const events = new AgentEvents(telemetry.provider.getTracer('invoice-example'));
|
|
5
|
+
try {
|
|
6
|
+
await Promise.all(
|
|
7
|
+
['invoice-a', 'invoice-b'].map((invoiceId) =>
|
|
8
|
+
events.withContext({ 'fluency.run.id': invoiceId }, () =>
|
|
9
|
+
events.trace('Review invoice', async () => {
|
|
10
|
+
await Promise.resolve();
|
|
11
|
+
let outcome = 'approved';
|
|
12
|
+
try {
|
|
13
|
+
await events.trace('Check policy', () => {
|
|
14
|
+
if (invoiceId === 'invoice-b') throw new Error('Synthetic policy lookup failure');
|
|
15
|
+
});
|
|
16
|
+
} catch {
|
|
17
|
+
outcome = 'escalated';
|
|
18
|
+
}
|
|
19
|
+
events.capture('Invoice decision', {
|
|
20
|
+
'fluency.artifact.source': 'erp:demo:invoices',
|
|
21
|
+
'fluency.artifact.external_id': invoiceId,
|
|
22
|
+
'fluency.artifact.type': 'invoice',
|
|
23
|
+
'fluency.outcome': outcome,
|
|
24
|
+
});
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
} finally {
|
|
30
|
+
await telemetry.shutdown();
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Compare a native span event, a captured child, and capture without a parent."""
|
|
2
|
+
|
|
3
|
+
from fluency_workflow_telemetry import AgentEvents, configure
|
|
4
|
+
from opentelemetry import trace
|
|
5
|
+
from opentelemetry.sdk.resources import Resource
|
|
6
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
7
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
8
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
existing = InMemorySpanExporter()
|
|
13
|
+
provider = TracerProvider(resource=Resource.create({"service.name": "existing-invoice-agent"}))
|
|
14
|
+
provider.add_span_processor(SimpleSpanProcessor(existing))
|
|
15
|
+
telemetry = configure(provider=provider)
|
|
16
|
+
tracer = provider.get_tracer("customer-instrumentation")
|
|
17
|
+
events = AgentEvents(tracer)
|
|
18
|
+
try:
|
|
19
|
+
with tracer.start_as_current_span("Review invoice") as parent:
|
|
20
|
+
parent.set_attribute("customer.invoice.id", "invoice-context")
|
|
21
|
+
parent.add_event("invoice.approved", {"customer.invoice.id": "invoice-context"})
|
|
22
|
+
child = events.capture("Approval notification queued", attributes={"customer.invoice.id": "invoice-context"})
|
|
23
|
+
assert child.trace_id == parent.get_span_context().trace_id
|
|
24
|
+
assert child.span_id != parent.get_span_context().span_id
|
|
25
|
+
assert trace.get_current_span() is parent
|
|
26
|
+
# A child is already ended; the parent and its annotation are still open.
|
|
27
|
+
assert len(existing.get_finished_spans()) == 1
|
|
28
|
+
|
|
29
|
+
standalone = events.capture("Standalone reconciliation")
|
|
30
|
+
assert standalone.trace_id != child.trace_id
|
|
31
|
+
spans = {span.name: span for span in existing.get_finished_spans()}
|
|
32
|
+
assert spans["Approval notification queued"].parent.span_id == parent.get_span_context().span_id
|
|
33
|
+
assert spans["Standalone reconciliation"].parent is None
|
|
34
|
+
assert spans["Review invoice"].events[0].name == "invoice.approved"
|
|
35
|
+
finally:
|
|
36
|
+
telemetry.shutdown()
|
|
37
|
+
provider.shutdown()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
4
|
+
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
5
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
6
|
+
import { AgentEvents, createSpanProcessor } from '@usefluency/workflow-telemetry';
|
|
7
|
+
|
|
8
|
+
const existing = new InMemorySpanExporter();
|
|
9
|
+
const telemetry = createSpanProcessor();
|
|
10
|
+
const provider = new NodeTracerProvider({
|
|
11
|
+
resource: resourceFromAttributes({ 'service.name': 'existing-invoice-agent' }),
|
|
12
|
+
spanProcessors: [new SimpleSpanProcessor(existing), telemetry.processor],
|
|
13
|
+
});
|
|
14
|
+
provider.register();
|
|
15
|
+
const tracer = provider.getTracer('customer-instrumentation');
|
|
16
|
+
const events = new AgentEvents(tracer);
|
|
17
|
+
try {
|
|
18
|
+
const child = await tracer.startActiveSpan('Review invoice', async (parent) => {
|
|
19
|
+
try {
|
|
20
|
+
await Promise.resolve();
|
|
21
|
+
parent.setAttribute('customer.invoice.id', 'invoice-context');
|
|
22
|
+
parent.addEvent('invoice.approved', { 'customer.invoice.id': 'invoice-context' });
|
|
23
|
+
const captured = events.capture('Approval notification queued', { 'customer.invoice.id': 'invoice-context' });
|
|
24
|
+
assert.equal(captured.traceId, parent.spanContext().traceId);
|
|
25
|
+
assert.notEqual(captured.spanId, parent.spanContext().spanId);
|
|
26
|
+
assert.equal(trace.getActiveSpan(), parent);
|
|
27
|
+
// The captured child ends without ending the customer's active operation.
|
|
28
|
+
assert.equal(existing.getFinishedSpans().length, 1);
|
|
29
|
+
return captured;
|
|
30
|
+
} finally {
|
|
31
|
+
parent.end();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const standalone = events.capture('Standalone reconciliation');
|
|
35
|
+
assert.notEqual(standalone.traceId, child.traceId);
|
|
36
|
+
const spans = existing.getFinishedSpans();
|
|
37
|
+
const parent = spans.find((span) => span.name === 'Review invoice')!;
|
|
38
|
+
assert.equal(
|
|
39
|
+
spans.find((span) => span.name === 'Approval notification queued')!.parentSpanContext?.spanId,
|
|
40
|
+
parent.spanContext().spanId
|
|
41
|
+
);
|
|
42
|
+
assert.equal(spans.find((span) => span.name === 'Standalone reconciliation')!.parentSpanContext, undefined);
|
|
43
|
+
assert.equal(parent.events[0].name, 'invoice.approved');
|
|
44
|
+
} finally {
|
|
45
|
+
await telemetry.shutdown();
|
|
46
|
+
await provider.shutdown();
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Merge this exporter into the customer's existing Collector configuration.
|
|
2
|
+
exporters:
|
|
3
|
+
otlp_http/fluency:
|
|
4
|
+
traces_endpoint: ${env:FLUENCY_OTLP_TRACES_ENDPOINT}
|
|
5
|
+
headers:
|
|
6
|
+
Authorization: Bearer ${env:FLUENCY_WORKFLOW_API_KEY}
|
|
7
|
+
# Append otlp_http/fluency to the existing service.pipelines.traces.exporters list.
|
|
8
|
+
# Keep the existing destinations. Use a separate traces pipeline if Fluency needs
|
|
9
|
+
# its own filtering or redaction; do not apply it to the customer's other exports.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Native OTel only: keep an existing destination and add Fluency export."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
6
|
+
from opentelemetry.sdk.resources import Resource
|
|
7
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
8
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor
|
|
9
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
# This setup represents the customer's existing provider and exporter.
|
|
14
|
+
existing = InMemorySpanExporter()
|
|
15
|
+
provider = TracerProvider(resource=Resource.create({"service.name": "existing-invoice-agent"}))
|
|
16
|
+
provider.add_span_processor(SimpleSpanProcessor(existing))
|
|
17
|
+
provider.add_span_processor(
|
|
18
|
+
BatchSpanProcessor(
|
|
19
|
+
OTLPSpanExporter(
|
|
20
|
+
endpoint=os.environ["FLUENCY_OTLP_TRACES_ENDPOINT"], headers={"Authorization": f"Bearer {os.environ['FLUENCY_WORKFLOW_API_KEY']}"}
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
try:
|
|
25
|
+
with provider.get_tracer("existing-instrumentation").start_as_current_span("Native invoice action") as span:
|
|
26
|
+
span.set_attribute("fluency.artifact.external_id", "invoice-native")
|
|
27
|
+
span.set_attribute("fluency.outcome", "approved")
|
|
28
|
+
provider.force_flush()
|
|
29
|
+
assert len(existing.get_finished_spans()) == 1
|
|
30
|
+
finally:
|
|
31
|
+
provider.shutdown()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
3
|
+
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
4
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
5
|
+
import { AgentEvents, createSpanProcessor } from '@usefluency/workflow-telemetry';
|
|
6
|
+
|
|
7
|
+
// Add the Fluency processor where the application constructs its provider.
|
|
8
|
+
const existing = new InMemorySpanExporter();
|
|
9
|
+
const fluency = createSpanProcessor();
|
|
10
|
+
const provider = new NodeTracerProvider({
|
|
11
|
+
resource: resourceFromAttributes({ 'service.name': 'existing-invoice-agent' }),
|
|
12
|
+
spanProcessors: [new SimpleSpanProcessor(existing), fluency.processor],
|
|
13
|
+
});
|
|
14
|
+
provider.register();
|
|
15
|
+
try {
|
|
16
|
+
const tracer = provider.getTracer('existing-instrumentation');
|
|
17
|
+
const events = new AgentEvents(tracer);
|
|
18
|
+
tracer.startActiveSpan('Native invoice action', (span) => {
|
|
19
|
+
events.enrich({ 'fluency.artifact.external_id': 'invoice-native', 'fluency.outcome': 'approved' });
|
|
20
|
+
span.end();
|
|
21
|
+
});
|
|
22
|
+
await fluency.shutdown();
|
|
23
|
+
assert.equal(existing.getFinishedSpans().length, 1);
|
|
24
|
+
tracer.startSpan('Existing destination still active').end();
|
|
25
|
+
assert.equal(existing.getFinishedSpans().length, 2);
|
|
26
|
+
} finally {
|
|
27
|
+
await provider.shutdown();
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Capture a LangChain/LangGraph invoice agent and its business outcome."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from fluency_workflow_telemetry import configure
|
|
7
|
+
from langchain.agents import create_agent
|
|
8
|
+
from langchain_openai import ChatOpenAI
|
|
9
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor, get_current_span
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def review_invoice(model, events, invoice_id):
|
|
13
|
+
def approve_invoice() -> str:
|
|
14
|
+
"""Approve the invoice after validating its purchase order."""
|
|
15
|
+
# LangChain's callback span is not necessarily the active OTel span.
|
|
16
|
+
tool_span = get_current_span()
|
|
17
|
+
events.enrich({"fluency.artifact.external_id": invoice_id, "fluency.artifact.source": "erp:invoices"}, span=tool_span)
|
|
18
|
+
events.capture("Invoice approved", parent=tool_span, attributes={"fluency.outcome": "approved", "fluency.artifact.external_id": invoice_id})
|
|
19
|
+
return "Approved"
|
|
20
|
+
|
|
21
|
+
agent = create_agent(model, tools=[approve_invoice])
|
|
22
|
+
with events.span("Review invoice", attributes={"fluency.run.id": invoice_id, "fluency.artifact.external_id": invoice_id}):
|
|
23
|
+
return await agent.ainvoke({"messages": [{"role": "user", "content": f"Approve invoice {invoice_id} using the approval tool."}]})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
telemetry = configure(service_name="invoice-agent")
|
|
28
|
+
LangChainInstrumentor().instrument(tracer_provider=telemetry.provider)
|
|
29
|
+
try:
|
|
30
|
+
model = ChatOpenAI(model=os.environ["OPENAI_MODEL"])
|
|
31
|
+
await review_invoice(model, telemetry.events, "INV-1042")
|
|
32
|
+
finally:
|
|
33
|
+
telemetry.shutdown()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Capture OpenAI Agents runs alongside their existing tracing processors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from agents import Agent, Runner, function_tool
|
|
7
|
+
from fluency_workflow_telemetry import configure
|
|
8
|
+
from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def review_invoice(model, events, invoice_id):
|
|
12
|
+
@function_tool
|
|
13
|
+
def approve_invoice() -> str:
|
|
14
|
+
"""Approve the invoice after validating its purchase order."""
|
|
15
|
+
events.enrich({"fluency.artifact.external_id": invoice_id})
|
|
16
|
+
events.capture("Invoice approved", attributes={"fluency.artifact.external_id": invoice_id, "fluency.outcome": "approved"})
|
|
17
|
+
return "Approved"
|
|
18
|
+
|
|
19
|
+
agent = Agent(name="Invoice reviewer", instructions="Use the approval tool to approve the invoice.", model=model, tools=[approve_invoice])
|
|
20
|
+
with events.span("Review invoice", attributes={"fluency.run.id": invoice_id, "fluency.artifact.external_id": invoice_id}):
|
|
21
|
+
return await Runner.run(agent, f"Approve invoice {invoice_id}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
telemetry = configure(service_name="invoice-agent")
|
|
26
|
+
# The instrumentor defaults to replacing existing Agents tracing processors.
|
|
27
|
+
OpenAIAgentsInstrumentor().instrument(tracer_provider=telemetry.provider, exclusive_processor=False)
|
|
28
|
+
try:
|
|
29
|
+
await review_invoice(os.environ["OPENAI_MODEL"], telemetry.events, "INV-1042")
|
|
30
|
+
finally:
|
|
31
|
+
await asyncio.to_thread(telemetry.shutdown)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Capture direct OpenAI Responses API calls inside an invoice workflow."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from fluency_workflow_telemetry import configure
|
|
7
|
+
from openai import AsyncOpenAI
|
|
8
|
+
from openinference.instrumentation.openai import OpenAIInstrumentor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def review_invoice(client, events, model, invoice_id):
|
|
12
|
+
with events.span("Review invoice", attributes={"fluency.artifact.external_id": invoice_id}):
|
|
13
|
+
response = await client.responses.create(model=model, input="Summarize the checks needed before approving this invoice.")
|
|
14
|
+
if response.status != "completed":
|
|
15
|
+
raise RuntimeError(f"Invoice review did not complete: {response.status}")
|
|
16
|
+
events.capture("Invoice review prepared", attributes={"fluency.artifact.external_id": invoice_id, "fluency.outcome": "ready_for_review"})
|
|
17
|
+
return response.output_text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def stream_review(client, events, model, invoice_id):
|
|
21
|
+
with events.span("Review invoice", attributes={"fluency.artifact.external_id": invoice_id}):
|
|
22
|
+
async with client.responses.stream(model=model, input="Summarize the checks needed before approving this invoice.") as stream:
|
|
23
|
+
async for event in stream:
|
|
24
|
+
if event.type == "response.output_text.delta":
|
|
25
|
+
yield event.delta
|
|
26
|
+
response = await stream.get_final_response()
|
|
27
|
+
if response.status != "completed":
|
|
28
|
+
raise RuntimeError(f"Invoice review did not complete: {response.status}")
|
|
29
|
+
events.capture("Invoice review prepared", attributes={"fluency.artifact.external_id": invoice_id, "fluency.outcome": "ready_for_review"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
telemetry = configure(service_name="invoice-agent")
|
|
34
|
+
OpenAIInstrumentor().instrument(tracer_provider=telemetry.provider)
|
|
35
|
+
try:
|
|
36
|
+
async with AsyncOpenAI() as client:
|
|
37
|
+
await review_invoice(client, telemetry.events, os.environ["OPENAI_MODEL"], "INV-1042")
|
|
38
|
+
finally:
|
|
39
|
+
await asyncio.to_thread(telemetry.shutdown)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Pydantic AI integration without model credentials; TestModel exercises a real tool."""
|
|
2
|
+
|
|
3
|
+
from fluency_workflow_telemetry import AgentEvents, configure
|
|
4
|
+
from opentelemetry.sdk.resources import Resource
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
6
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
7
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
8
|
+
from pydantic_ai import Agent
|
|
9
|
+
from pydantic_ai.capabilities import Instrumentation
|
|
10
|
+
from pydantic_ai.models.instrumented import InstrumentationSettings
|
|
11
|
+
from pydantic_ai.models.test import TestModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
existing = InMemorySpanExporter()
|
|
16
|
+
provider = TracerProvider(resource=Resource.create({"service.name": "invoice-agent-pydantic"}))
|
|
17
|
+
provider.add_span_processor(SimpleSpanProcessor(existing))
|
|
18
|
+
telemetry = configure(provider=provider)
|
|
19
|
+
events = AgentEvents(provider.get_tracer("invoice-example"))
|
|
20
|
+
agent = Agent(TestModel(), capabilities=[Instrumentation(settings=InstrumentationSettings(tracer_provider=provider, include_content=False))])
|
|
21
|
+
|
|
22
|
+
@agent.tool_plain
|
|
23
|
+
def approve_invoice() -> str:
|
|
24
|
+
events.enrich({"fluency.artifact.source": "erp:demo:invoices", "fluency.artifact.external_id": "invoice-pydantic"})
|
|
25
|
+
events.capture("Invoice approved", attributes={"fluency.outcome": "approved"})
|
|
26
|
+
return "approved"
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with events.context({"fluency.run.id": "run-pydantic"}):
|
|
30
|
+
agent.run_sync("Approve the synthetic invoice")
|
|
31
|
+
telemetry.flush()
|
|
32
|
+
assert any(span.attributes.get("gen_ai.operation.name") == "execute_tool" for span in existing.get_finished_spans())
|
|
33
|
+
assert any(span.name == "Invoice approved" for span in existing.get_finished_spans())
|
|
34
|
+
finally:
|
|
35
|
+
telemetry.shutdown()
|
|
36
|
+
provider.shutdown()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Record an ERP posting workflow without an AI framework or model credentials."""
|
|
2
|
+
|
|
3
|
+
from fluency_workflow_telemetry import configure
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def post_invoice(events, invoice_id, post_to_erp):
|
|
7
|
+
with events.span("Post invoice", attributes={"fluency.run.id": invoice_id, "fluency.artifact.external_id": invoice_id}):
|
|
8
|
+
with events.span("Submit to ERP"):
|
|
9
|
+
erp_id = post_to_erp(invoice_id)
|
|
10
|
+
events.capture("Invoice posted", attributes={"fluency.outcome": "posted", "erp.document_id": erp_id})
|
|
11
|
+
return erp_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
telemetry = configure(service_name="invoice-rpa")
|
|
16
|
+
try:
|
|
17
|
+
# Replace this demo callback with your bot's existing ERP operation.
|
|
18
|
+
post_invoice(telemetry.events, "INV-1042", lambda invoice_id: f"ERP-{invoice_id}")
|
|
19
|
+
finally:
|
|
20
|
+
telemetry.shutdown()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { configure } from '@usefluency/workflow-telemetry';
|
|
3
|
+
import type { AgentEvents } from '@usefluency/workflow-telemetry';
|
|
4
|
+
|
|
5
|
+
export async function postInvoice(events: AgentEvents, invoiceId: string, postToErp: (id: string) => Promise<string>) {
|
|
6
|
+
return events.trace(
|
|
7
|
+
'Post invoice',
|
|
8
|
+
async () => {
|
|
9
|
+
const erpId = await events.trace('Submit to ERP', () => postToErp(invoiceId));
|
|
10
|
+
events.capture('Invoice posted', { 'fluency.outcome': 'posted', 'erp.document_id': erpId });
|
|
11
|
+
return erpId;
|
|
12
|
+
},
|
|
13
|
+
{ 'fluency.run.id': invoiceId, 'fluency.artifact.external_id': invoiceId }
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
18
|
+
const telemetry = configure({ serviceName: 'invoice-rpa' });
|
|
19
|
+
try {
|
|
20
|
+
// Replace the demo callback with your bot's existing ERP operation.
|
|
21
|
+
await postInvoice(telemetry.events, 'INV-1042', async (id) => `ERP-${id}`);
|
|
22
|
+
} finally {
|
|
23
|
+
await telemetry.shutdown();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { openai } from '@ai-sdk/openai';
|
|
3
|
+
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
4
|
+
import { generateText, registerTelemetry, stepCountIs, streamText, tool, jsonSchema } from 'ai';
|
|
5
|
+
import type { LanguageModel } from 'ai';
|
|
6
|
+
import { configure } from '@usefluency/workflow-telemetry';
|
|
7
|
+
import type { AgentEvents } from '@usefluency/workflow-telemetry';
|
|
8
|
+
|
|
9
|
+
export async function reviewInvoice(model: LanguageModel, events: AgentEvents, invoiceId: string) {
|
|
10
|
+
return events.trace(
|
|
11
|
+
'Review invoice',
|
|
12
|
+
async () => {
|
|
13
|
+
const result = await generateText({
|
|
14
|
+
model,
|
|
15
|
+
prompt: `Approve invoice ${invoiceId} using the approval tool.`,
|
|
16
|
+
stopWhen: stepCountIs(3),
|
|
17
|
+
tools: {
|
|
18
|
+
approveInvoice: tool({
|
|
19
|
+
description: 'Approve the invoice after checking its purchase order.',
|
|
20
|
+
inputSchema: jsonSchema<Record<string, never>>({
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
}),
|
|
25
|
+
execute: () => {
|
|
26
|
+
events.enrich({ 'fluency.artifact.external_id': invoiceId });
|
|
27
|
+
events.capture('Invoice approved', {
|
|
28
|
+
'fluency.artifact.external_id': invoiceId,
|
|
29
|
+
'fluency.outcome': 'approved',
|
|
30
|
+
});
|
|
31
|
+
return { approved: true };
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return result.text;
|
|
37
|
+
},
|
|
38
|
+
{ 'fluency.run.id': invoiceId, 'fluency.artifact.external_id': invoiceId }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function summarizeInvoice(model: LanguageModel, events: AgentEvents, invoiceId: string) {
|
|
43
|
+
return events.trace(
|
|
44
|
+
'Summarize invoice',
|
|
45
|
+
async () => {
|
|
46
|
+
const result = streamText({ model, prompt: `Summarize the review for invoice ${invoiceId}.` });
|
|
47
|
+
let text = '';
|
|
48
|
+
// Consume the stream before ending the operation or flushing telemetry.
|
|
49
|
+
for await (const chunk of result.textStream) text += chunk;
|
|
50
|
+
return text;
|
|
51
|
+
},
|
|
52
|
+
{ 'fluency.artifact.external_id': invoiceId }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
57
|
+
const telemetry = configure({ serviceName: 'invoice-agent' });
|
|
58
|
+
registerTelemetry(new OpenTelemetry());
|
|
59
|
+
try {
|
|
60
|
+
if (!process.env.OPENAI_MODEL) throw new Error('Set OPENAI_MODEL to a model available to your account');
|
|
61
|
+
await reviewInvoice(openai(process.env.OPENAI_MODEL), telemetry.events, 'INV-1042');
|
|
62
|
+
} finally {
|
|
63
|
+
await telemetry.shutdown();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Emit synthetic workflow evidence with standard OpenTelemetry APIs."""
|
|
2
|
+
|
|
3
|
+
from fluency_workflow_telemetry import configure
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
telemetry = configure(service_name="invoice-worker")
|
|
8
|
+
tracer = telemetry.provider.get_tracer("invoice-workflow-example")
|
|
9
|
+
try:
|
|
10
|
+
with tracer.start_as_current_span("Process invoice", attributes={"customer.invoice.id": "invoice-example"}) as workflow:
|
|
11
|
+
with tracer.start_as_current_span("Validate invoice"):
|
|
12
|
+
pass
|
|
13
|
+
workflow.add_event("approval", {"outcome": "approved"})
|
|
14
|
+
finally:
|
|
15
|
+
telemetry.shutdown()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { configure } from '@usefluency/workflow-telemetry';
|
|
2
|
+
|
|
3
|
+
const telemetry = configure({ serviceName: 'invoice-worker' });
|
|
4
|
+
const tracer = telemetry.provider.getTracer('invoice-workflow-example');
|
|
5
|
+
try {
|
|
6
|
+
tracer.startActiveSpan(
|
|
7
|
+
'Process invoice',
|
|
8
|
+
{ attributes: { 'customer.invoice.id': 'invoice-example' } },
|
|
9
|
+
(workflow) => {
|
|
10
|
+
try {
|
|
11
|
+
tracer.startActiveSpan('Validate invoice', (step) => step.end());
|
|
12
|
+
workflow.addEvent('approval', { outcome: 'approved' });
|
|
13
|
+
} finally {
|
|
14
|
+
workflow.end();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
} finally {
|
|
19
|
+
await telemetry.shutdown();
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Optional agent-event instrumentation using the application's OpenTelemetry tracer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from time import time_ns
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
|
|
16
|
+
from opentelemetry.trace import Span, SpanContext, Tracer
|
|
17
|
+
from opentelemetry.util.types import AttributeValue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentEvents:
|
|
21
|
+
"""Capture spans without installing or replacing a provider, sampler or exporter."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, tracer: Tracer | None = None) -> None:
|
|
24
|
+
self._tracer = tracer if tracer is not None else trace.get_tracer("usefluency-workflow-telemetry", version("usefluency-workflow-telemetry"))
|
|
25
|
+
self._context: ContextVar[Mapping[str, AttributeValue] | None] = ContextVar("fluency.agent-events.attributes", default=None)
|
|
26
|
+
|
|
27
|
+
def _attributes(self, attributes: Mapping[str, AttributeValue] | None) -> dict[str, AttributeValue]:
|
|
28
|
+
return {**(self._context.get() or {}), **(attributes or {})}
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def context(self, attributes: Mapping[str, AttributeValue]) -> Iterator[None]:
|
|
32
|
+
"""Scope attributes to this helper instance; never add them to outbound baggage."""
|
|
33
|
+
token = self._context.set(self._attributes(attributes))
|
|
34
|
+
try:
|
|
35
|
+
yield
|
|
36
|
+
finally:
|
|
37
|
+
self._context.reset(token)
|
|
38
|
+
|
|
39
|
+
def enrich(self, attributes: Mapping[str, AttributeValue], *, span: Span | None = None) -> bool:
|
|
40
|
+
"""Return False if no recording span exists; never create a replacement span."""
|
|
41
|
+
target = span if span is not None else trace.get_current_span()
|
|
42
|
+
if not target.is_recording():
|
|
43
|
+
return False
|
|
44
|
+
target.set_attributes(self._attributes(attributes))
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def capture(self, name: str, *, attributes: Mapping[str, AttributeValue] | None = None, parent: Span | None = None) -> SpanContext:
|
|
48
|
+
"""Record a point-in-time action as a completed span under the current parent."""
|
|
49
|
+
timestamp = time_ns()
|
|
50
|
+
context = trace.set_span_in_context(parent) if parent is not None else None
|
|
51
|
+
span = self._tracer.start_span(name, context=context, attributes=self._attributes(attributes), start_time=timestamp)
|
|
52
|
+
span.end(end_time=timestamp)
|
|
53
|
+
return span.get_span_context()
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def span(self, name: str, *, attributes: Mapping[str, AttributeValue] | None = None) -> Iterator[Span]:
|
|
57
|
+
"""Use inside sync or async functions; keep streaming work inside the block."""
|
|
58
|
+
with self._tracer.start_as_current_span(name, attributes=self._attributes(attributes)) as span:
|
|
59
|
+
yield span
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: usefluency-workflow-telemetry
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
License: Proprietary
|
|
5
|
+
Classifier: License :: Other/Proprietary License
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: opentelemetry-api<2,>=1.42
|
|
10
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.42
|
|
11
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.42
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Workflow telemetry SDKs
|
|
15
|
+
|
|
16
|
+
Capture the work your agents and automations perform in Fluency. Connect existing OpenTelemetry instrumentation, then add business identifiers and outcomes with the SDK's event helpers.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install usefluency-workflow-telemetry
|
|
22
|
+
npm install @usefluency/workflow-telemetry
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Python requires 3.12 or later and imports as `fluency_workflow_telemetry`. JavaScript and TypeScript require Node.js 24 or later; ESM and CommonJS are supported.
|
|
26
|
+
|
|
27
|
+
## Get started
|
|
28
|
+
|
|
29
|
+
Your Fluency contact provides your organization's regional endpoint and ingestion key. Configure them in your server-side environment:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
FLUENCY_OTLP_TRACES_ENDPOINT=https://YOUR-REGIONAL-ENDPOINT/ext/otel/v1/traces
|
|
33
|
+
FLUENCY_WORKFLOW_API_KEY=YOUR-ORGANIZATION-API-KEY
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Choose the guide for your application:
|
|
37
|
+
|
|
38
|
+
- [LangChain and LangGraph](https://docs.usefluency.com/fluency-workflow-telemetry-langchain): connect a Python agent, capture its tools, and attach business context.
|
|
39
|
+
- [Vercel AI SDK](https://docs.usefluency.com/fluency-workflow-telemetry-vercel): capture Node.js model calls, tools, and streaming work.
|
|
40
|
+
- [OpenAI](https://docs.usefluency.com/fluency-workflow-telemetry-openai): connect direct Python API calls or Agents SDK workflows while preserving existing tracing.
|
|
41
|
+
- [RPA and custom workflows](https://docs.usefluency.com/fluency-workflow-telemetry-rpa): record steps and outcomes from Python or TypeScript automations.
|
|
42
|
+
- [Existing OpenTelemetry](https://docs.usefluency.com/fluency-workflow-telemetry-opentelemetry): add Fluency while keeping your current telemetry destination.
|
|
43
|
+
- [Business context and capture](https://docs.usefluency.com/fluency-workflow-telemetry-business-context): enrich an operation or record a business action.
|
|
44
|
+
|
|
45
|
+
After `configure()`, use `telemetry.events` for `enrich`, `capture`, and timed operations. Framework integrations capture the operations they instrument; the exporter alone does not discover agent or bot activity.
|
|
46
|
+
|
|
47
|
+
Exact methods and lifecycle behavior are in the [SDK reference](SDK.md). [Executable examples](examples/README.md) accompany the guides. Maintainers use [release instructions](https://github.com/farmorsoftware/fluency-monorepo/blob/main/packages/workflow-telemetry/RELEASING.md).
|
|
48
|
+
|
|
49
|
+
This software is proprietary; see [LICENSE](LICENSE).
|