agentflow-runtime 1.1.0__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.
- agentflow_runtime-1.1.0.dist-info/METADATA +55 -0
- agentflow_runtime-1.1.0.dist-info/RECORD +100 -0
- agentflow_runtime-1.1.0.dist-info/WHEEL +4 -0
- agentflow_runtime-1.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +0 -0
- src/constants.py +3 -0
- src/ingestion/__init__.py +0 -0
- src/ingestion/cdc/__init__.py +5 -0
- src/ingestion/cdc/normalizer.py +186 -0
- src/ingestion/connectors/__init__.py +0 -0
- src/ingestion/connectors/mysql_cdc.py +63 -0
- src/ingestion/connectors/postgres_cdc.py +68 -0
- src/ingestion/producers/__init__.py +0 -0
- src/ingestion/producers/event_producer.py +237 -0
- src/ingestion/schemas/__init__.py +0 -0
- src/ingestion/schemas/events.py +147 -0
- src/ingestion/tenant_router.py +80 -0
- src/logger.py +41 -0
- src/orchestration/__init__.py +0 -0
- src/orchestration/dags/__init__.py +0 -0
- src/orchestration/dags/daily_batch.py +201 -0
- src/processing/__init__.py +0 -0
- src/processing/event_replayer.py +250 -0
- src/processing/flink_jobs/Dockerfile +55 -0
- src/processing/flink_jobs/__init__.py +0 -0
- src/processing/flink_jobs/checkpointing.py +32 -0
- src/processing/flink_jobs/session_aggregation.py +212 -0
- src/processing/flink_jobs/session_aggregator.py +199 -0
- src/processing/flink_jobs/stream_processor.py +316 -0
- src/processing/iceberg_sink.py +348 -0
- src/processing/local_pipeline.py +452 -0
- src/processing/outbox.py +273 -0
- src/processing/tracing.py +36 -0
- src/processing/transformations/__init__.py +0 -0
- src/processing/transformations/enrichment.py +125 -0
- src/quality/__init__.py +0 -0
- src/quality/monitors/__init__.py +0 -0
- src/quality/monitors/freshness_monitor.py +166 -0
- src/quality/monitors/metrics_collector.py +367 -0
- src/quality/validators/__init__.py +0 -0
- src/quality/validators/schema_validator.py +119 -0
- src/quality/validators/semantic_validator.py +202 -0
- src/serving/__init__.py +0 -0
- src/serving/api/__init__.py +0 -0
- src/serving/api/alert_dispatcher.py +51 -0
- src/serving/api/alerts/__init__.py +38 -0
- src/serving/api/alerts/dispatcher.py +299 -0
- src/serving/api/alerts/escalation.py +290 -0
- src/serving/api/alerts/evaluator.py +81 -0
- src/serving/api/alerts/history.py +115 -0
- src/serving/api/analytics.py +543 -0
- src/serving/api/auth/__init__.py +46 -0
- src/serving/api/auth/key_rotation.py +400 -0
- src/serving/api/auth/manager.py +406 -0
- src/serving/api/auth/middleware.py +331 -0
- src/serving/api/main.py +390 -0
- src/serving/api/middleware/logging.py +41 -0
- src/serving/api/middleware/tracing.py +51 -0
- src/serving/api/rate_limiter.py +76 -0
- src/serving/api/routers/__init__.py +0 -0
- src/serving/api/routers/admin.py +150 -0
- src/serving/api/routers/admin_ui.py +93 -0
- src/serving/api/routers/agent_query.py +639 -0
- src/serving/api/routers/alerts.py +134 -0
- src/serving/api/routers/batch.py +231 -0
- src/serving/api/routers/contracts.py +98 -0
- src/serving/api/routers/deadletter.py +337 -0
- src/serving/api/routers/lineage.py +218 -0
- src/serving/api/routers/search.py +103 -0
- src/serving/api/routers/slo.py +231 -0
- src/serving/api/routers/stream.py +141 -0
- src/serving/api/routers/webhooks.py +93 -0
- src/serving/api/security.py +83 -0
- src/serving/api/telemetry.py +66 -0
- src/serving/api/templates/admin.html +214 -0
- src/serving/api/versioning.py +328 -0
- src/serving/api/webhook_dispatcher.py +423 -0
- src/serving/backends/__init__.py +117 -0
- src/serving/backends/clickhouse_backend.py +310 -0
- src/serving/backends/duckdb_backend.py +268 -0
- src/serving/cache.py +169 -0
- src/serving/db_pool.py +105 -0
- src/serving/masking.py +122 -0
- src/serving/semantic_layer/__init__.py +0 -0
- src/serving/semantic_layer/catalog.py +177 -0
- src/serving/semantic_layer/contract_registry.py +258 -0
- src/serving/semantic_layer/entity_type_registry.py +107 -0
- src/serving/semantic_layer/nl_engine.py +189 -0
- src/serving/semantic_layer/query/__init__.py +3 -0
- src/serving/semantic_layer/query/contracts.py +47 -0
- src/serving/semantic_layer/query/engine.py +81 -0
- src/serving/semantic_layer/query/entity_queries.py +221 -0
- src/serving/semantic_layer/query/metric_queries.py +84 -0
- src/serving/semantic_layer/query/nl_queries.py +305 -0
- src/serving/semantic_layer/query/sql_builder.py +113 -0
- src/serving/semantic_layer/query/sql_guard.py +3 -0
- src/serving/semantic_layer/query_engine.py +5 -0
- src/serving/semantic_layer/schema_evolution.py +175 -0
- src/serving/semantic_layer/search_index.py +337 -0
- src/serving/semantic_layer/sql_guard.py +56 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Simulates realistic e-commerce event streams for local development.
|
|
2
|
+
|
|
3
|
+
Produces orders, payments, clickstream, and product events to Kafka topics
|
|
4
|
+
with configurable throughput. Events follow realistic distributions:
|
|
5
|
+
- Orders follow a daily pattern (peaks at 10am, 2pm, 8pm)
|
|
6
|
+
- Payments follow orders with 0-5s delay
|
|
7
|
+
- Clickstream is 10x order volume
|
|
8
|
+
- Products update in batches every ~60s
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
from confluent_kafka import Producer
|
|
20
|
+
from pydantic_settings import BaseSettings
|
|
21
|
+
|
|
22
|
+
from src.ingestion.schemas.events import (
|
|
23
|
+
ClickstreamEvent,
|
|
24
|
+
Currency,
|
|
25
|
+
EventType,
|
|
26
|
+
OrderEvent,
|
|
27
|
+
OrderItem,
|
|
28
|
+
OrderStatus,
|
|
29
|
+
PaymentEvent,
|
|
30
|
+
PaymentMethod,
|
|
31
|
+
ProductEvent,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProducerConfig(BaseSettings):
|
|
38
|
+
kafka_bootstrap_servers: str = "localhost:9092"
|
|
39
|
+
events_per_second: int = 100
|
|
40
|
+
order_ratio: float = 0.15
|
|
41
|
+
payment_ratio: float = 0.10
|
|
42
|
+
click_ratio: float = 0.70
|
|
43
|
+
product_ratio: float = 0.05
|
|
44
|
+
|
|
45
|
+
model_config = {"env_prefix": "PRODUCER_"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PRODUCT_CATALOG = [
|
|
49
|
+
("PROD-001", "Wireless Headphones", "electronics", Decimal("79.99")),
|
|
50
|
+
("PROD-002", "Running Shoes", "footwear", Decimal("129.99")),
|
|
51
|
+
("PROD-003", "Coffee Maker", "kitchen", Decimal("49.99")),
|
|
52
|
+
("PROD-004", "Mechanical Keyboard", "electronics", Decimal("149.99")),
|
|
53
|
+
("PROD-005", "Yoga Mat", "fitness", Decimal("34.99")),
|
|
54
|
+
("PROD-006", "Backpack", "accessories", Decimal("89.99")),
|
|
55
|
+
("PROD-007", "Water Bottle", "fitness", Decimal("24.99")),
|
|
56
|
+
("PROD-008", "Desk Lamp", "home", Decimal("44.99")),
|
|
57
|
+
("PROD-009", "Bluetooth Speaker", "electronics", Decimal("59.99")),
|
|
58
|
+
("PROD-010", "Sunglasses", "accessories", Decimal("119.99")),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
PAGES = ["/", "/products", "/cart", "/checkout", "/account", "/search"]
|
|
62
|
+
USER_AGENTS = [
|
|
63
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
64
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
65
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
|
|
66
|
+
"Mozilla/5.0 (Linux; Android 14; Pixel 8)",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DecimalEncoder(json.JSONEncoder):
|
|
71
|
+
def default(self, o):
|
|
72
|
+
if isinstance(o, Decimal):
|
|
73
|
+
return float(o)
|
|
74
|
+
return super().default(o)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _delivery_report(err, msg):
|
|
78
|
+
if err:
|
|
79
|
+
logger.error("delivery_failed", error=str(err), topic=msg.topic())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _now() -> datetime:
|
|
83
|
+
return datetime.now(UTC)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _uuid() -> str:
|
|
87
|
+
return str(uuid.uuid4())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def generate_order() -> tuple[str, OrderEvent]:
|
|
91
|
+
products = random.sample(PRODUCT_CATALOG, k=random.randint(1, 4))
|
|
92
|
+
items = [
|
|
93
|
+
OrderItem(
|
|
94
|
+
product_id=p[0],
|
|
95
|
+
quantity=random.randint(1, 3),
|
|
96
|
+
unit_price=p[3],
|
|
97
|
+
)
|
|
98
|
+
for p in products
|
|
99
|
+
]
|
|
100
|
+
total = sum((item.quantity * item.unit_price for item in items), Decimal(0))
|
|
101
|
+
order_id = f"ORD-{_now().strftime('%Y%m%d')}-{random.randint(1000, 9999)}"
|
|
102
|
+
|
|
103
|
+
event = OrderEvent(
|
|
104
|
+
event_id=_uuid(),
|
|
105
|
+
event_type=EventType.ORDER_CREATED,
|
|
106
|
+
timestamp=_now(),
|
|
107
|
+
source="web-store",
|
|
108
|
+
order_id=order_id,
|
|
109
|
+
user_id=f"USR-{random.randint(10000, 99999)}",
|
|
110
|
+
status=OrderStatus.PENDING,
|
|
111
|
+
items=items,
|
|
112
|
+
total_amount=total,
|
|
113
|
+
currency=Currency.USD,
|
|
114
|
+
)
|
|
115
|
+
return "orders.raw", event
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_payment(order_id: str | None = None) -> tuple[str, PaymentEvent]:
|
|
119
|
+
oid = order_id or f"ORD-{_now().strftime('%Y%m%d')}-{random.randint(1000, 9999)}"
|
|
120
|
+
event = PaymentEvent(
|
|
121
|
+
event_id=_uuid(),
|
|
122
|
+
event_type=EventType.PAYMENT_INITIATED,
|
|
123
|
+
timestamp=_now(),
|
|
124
|
+
source="payment-gateway",
|
|
125
|
+
payment_id=f"PAY-{_uuid()[:8]}",
|
|
126
|
+
order_id=oid,
|
|
127
|
+
user_id=f"USR-{random.randint(10000, 99999)}",
|
|
128
|
+
amount=Decimal(str(round(random.uniform(20, 500), 2))),
|
|
129
|
+
currency=Currency.USD,
|
|
130
|
+
method=random.choice(list(PaymentMethod)),
|
|
131
|
+
status="initiated",
|
|
132
|
+
)
|
|
133
|
+
return "payments.raw", event
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def generate_click() -> tuple[str, ClickstreamEvent]:
|
|
137
|
+
product = random.choice(PRODUCT_CATALOG) if random.random() > 0.4 else None
|
|
138
|
+
page = f"/products/{product[0]}" if product else random.choice(PAGES)
|
|
139
|
+
event = ClickstreamEvent(
|
|
140
|
+
event_id=_uuid(),
|
|
141
|
+
event_type=random.choice([EventType.CLICK, EventType.PAGE_VIEW, EventType.ADD_TO_CART]),
|
|
142
|
+
timestamp=_now(),
|
|
143
|
+
source="web-tracker",
|
|
144
|
+
session_id=f"SES-{_uuid()[:12]}",
|
|
145
|
+
user_id=f"USR-{random.randint(10000, 99999)}" if random.random() > 0.3 else None,
|
|
146
|
+
page_url=page,
|
|
147
|
+
referrer="https://google.com" if random.random() > 0.5 else None,
|
|
148
|
+
user_agent=random.choice(USER_AGENTS),
|
|
149
|
+
viewport_width=random.choice([375, 768, 1024, 1440, 1920]),
|
|
150
|
+
product_id=product[0] if product else None,
|
|
151
|
+
)
|
|
152
|
+
return "clicks.raw", event
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def generate_product() -> tuple[str, ProductEvent]:
|
|
156
|
+
product = random.choice(PRODUCT_CATALOG)
|
|
157
|
+
event = ProductEvent(
|
|
158
|
+
event_id=_uuid(),
|
|
159
|
+
event_type=EventType.PRODUCT_UPDATED,
|
|
160
|
+
timestamp=_now(),
|
|
161
|
+
source="inventory-service",
|
|
162
|
+
product_id=product[0],
|
|
163
|
+
name=product[1],
|
|
164
|
+
category=product[2],
|
|
165
|
+
price=product[3],
|
|
166
|
+
currency=Currency.USD,
|
|
167
|
+
in_stock=random.random() > 0.1,
|
|
168
|
+
stock_quantity=random.randint(0, 500),
|
|
169
|
+
)
|
|
170
|
+
return "products.cdc", event
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_producer():
|
|
174
|
+
config = ProducerConfig()
|
|
175
|
+
producer = Producer(
|
|
176
|
+
{
|
|
177
|
+
"bootstrap.servers": config.kafka_bootstrap_servers,
|
|
178
|
+
"linger.ms": 50,
|
|
179
|
+
"batch.num.messages": 500,
|
|
180
|
+
"compression.type": "lz4",
|
|
181
|
+
"acks": "all",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
generators: list[tuple] = [
|
|
186
|
+
(config.order_ratio, generate_order),
|
|
187
|
+
(config.payment_ratio, generate_payment),
|
|
188
|
+
(config.click_ratio, generate_click),
|
|
189
|
+
(config.product_ratio, generate_product),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
"producer_started",
|
|
194
|
+
eps=config.events_per_second,
|
|
195
|
+
bootstrap=config.kafka_bootstrap_servers,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
produced = 0
|
|
199
|
+
interval = 1.0 / config.events_per_second
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
while True:
|
|
203
|
+
roll = random.random()
|
|
204
|
+
cumulative = 0.0
|
|
205
|
+
|
|
206
|
+
for ratio, gen in generators:
|
|
207
|
+
cumulative += ratio
|
|
208
|
+
if roll <= cumulative:
|
|
209
|
+
topic, event = gen()
|
|
210
|
+
producer.produce(
|
|
211
|
+
topic,
|
|
212
|
+
key=event.event_id.encode(),
|
|
213
|
+
value=json.dumps(
|
|
214
|
+
event.model_dump(mode="json"),
|
|
215
|
+
cls=DecimalEncoder,
|
|
216
|
+
).encode(),
|
|
217
|
+
callback=_delivery_report,
|
|
218
|
+
)
|
|
219
|
+
produced += 1
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
if produced % 1000 == 0:
|
|
223
|
+
producer.flush()
|
|
224
|
+
logger.info("producer_progress", total_produced=produced)
|
|
225
|
+
|
|
226
|
+
producer.poll(0)
|
|
227
|
+
time.sleep(interval)
|
|
228
|
+
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
logger.info("producer_stopping", total_produced=produced)
|
|
231
|
+
finally:
|
|
232
|
+
producer.flush(timeout=10)
|
|
233
|
+
logger.info("producer_stopped", total_produced=produced)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
run_producer()
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Canonical event schemas for all data sources.
|
|
2
|
+
|
|
3
|
+
These Pydantic models serve as the single source of truth for event structure.
|
|
4
|
+
Used by producers (serialization), Flink jobs (validation), and the API (response types).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventType(StrEnum):
|
|
15
|
+
ORDER_CREATED = "order.created"
|
|
16
|
+
ORDER_UPDATED = "order.updated"
|
|
17
|
+
ORDER_CANCELLED = "order.cancelled"
|
|
18
|
+
PAYMENT_INITIATED = "payment.initiated"
|
|
19
|
+
PAYMENT_COMPLETED = "payment.completed"
|
|
20
|
+
PAYMENT_FAILED = "payment.failed"
|
|
21
|
+
CLICK = "click"
|
|
22
|
+
PAGE_VIEW = "page_view"
|
|
23
|
+
ADD_TO_CART = "add_to_cart"
|
|
24
|
+
PRODUCT_UPDATED = "product.updated"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CdcOperation(StrEnum):
|
|
28
|
+
SNAPSHOT = "snapshot"
|
|
29
|
+
INSERT = "insert"
|
|
30
|
+
UPDATE = "update"
|
|
31
|
+
DELETE = "delete"
|
|
32
|
+
DDL = "ddl"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CdcSource(StrEnum):
|
|
36
|
+
POSTGRES = "postgres_cdc"
|
|
37
|
+
MYSQL = "mysql_cdc"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Currency(StrEnum):
|
|
41
|
+
USD = "USD"
|
|
42
|
+
EUR = "EUR"
|
|
43
|
+
GBP = "GBP"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrderStatus(StrEnum):
|
|
47
|
+
PENDING = "pending"
|
|
48
|
+
CONFIRMED = "confirmed"
|
|
49
|
+
SHIPPED = "shipped"
|
|
50
|
+
DELIVERED = "delivered"
|
|
51
|
+
CANCELLED = "cancelled"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BaseEvent(BaseModel):
|
|
55
|
+
event_id: str = Field(..., pattern=r"^[a-f0-9\-]{36}$")
|
|
56
|
+
event_type: EventType
|
|
57
|
+
timestamp: datetime
|
|
58
|
+
source: str = Field(..., min_length=1, max_length=64)
|
|
59
|
+
|
|
60
|
+
@field_validator("timestamp")
|
|
61
|
+
@classmethod
|
|
62
|
+
def timestamp_not_future(cls, v: datetime) -> datetime:
|
|
63
|
+
now = datetime.now(UTC)
|
|
64
|
+
if v.tzinfo is None:
|
|
65
|
+
v = v.replace(tzinfo=UTC)
|
|
66
|
+
# Allow 5 minutes of clock skew
|
|
67
|
+
max_future = now.timestamp() + 300
|
|
68
|
+
if v.timestamp() > max_future:
|
|
69
|
+
msg = f"Event timestamp {v} is too far in the future"
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
return v
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CdcEvent(BaseModel):
|
|
75
|
+
event_id: str = Field(..., min_length=1)
|
|
76
|
+
event_type: str = Field(..., min_length=1, max_length=128)
|
|
77
|
+
operation: CdcOperation
|
|
78
|
+
timestamp: datetime
|
|
79
|
+
source: CdcSource
|
|
80
|
+
entity_type: str = Field(..., min_length=1, max_length=64)
|
|
81
|
+
entity_id: str = Field(..., min_length=1, max_length=256)
|
|
82
|
+
before: dict | None = None
|
|
83
|
+
after: dict | None = None
|
|
84
|
+
source_metadata: dict = Field(..., min_length=1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OrderItem(BaseModel):
|
|
88
|
+
product_id: str
|
|
89
|
+
quantity: int = Field(..., gt=0, le=1000)
|
|
90
|
+
unit_price: Decimal = Field(..., gt=0, decimal_places=2)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class OrderEvent(BaseEvent):
|
|
94
|
+
order_id: str = Field(..., pattern=r"^ORD-\d{8}-\d{4,}$")
|
|
95
|
+
user_id: str
|
|
96
|
+
status: OrderStatus
|
|
97
|
+
items: list[OrderItem] = Field(..., min_length=1)
|
|
98
|
+
total_amount: Decimal = Field(..., gt=0)
|
|
99
|
+
currency: Currency = Currency.USD
|
|
100
|
+
|
|
101
|
+
@field_validator("total_amount")
|
|
102
|
+
@classmethod
|
|
103
|
+
def total_matches_items(cls, v: Decimal, info) -> Decimal:
|
|
104
|
+
items = info.data.get("items")
|
|
105
|
+
if items:
|
|
106
|
+
expected = sum(item.quantity * item.unit_price for item in items)
|
|
107
|
+
if abs(v - expected) > Decimal("0.01"):
|
|
108
|
+
msg = f"Total {v} doesn't match sum of items {expected}"
|
|
109
|
+
raise ValueError(msg)
|
|
110
|
+
return v
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PaymentMethod(StrEnum):
|
|
114
|
+
CARD = "card"
|
|
115
|
+
BANK_TRANSFER = "bank_transfer"
|
|
116
|
+
WALLET = "wallet"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PaymentEvent(BaseEvent):
|
|
120
|
+
payment_id: str
|
|
121
|
+
order_id: str = Field(..., pattern=r"^ORD-\d{8}-\d{4,}$")
|
|
122
|
+
user_id: str
|
|
123
|
+
amount: Decimal = Field(..., gt=0)
|
|
124
|
+
currency: Currency = Currency.USD
|
|
125
|
+
method: PaymentMethod
|
|
126
|
+
status: str = Field(..., pattern=r"^(initiated|completed|failed|refunded)$")
|
|
127
|
+
failure_reason: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ClickstreamEvent(BaseEvent):
|
|
131
|
+
session_id: str
|
|
132
|
+
user_id: str | None = None # anonymous users allowed
|
|
133
|
+
page_url: str
|
|
134
|
+
referrer: str | None = None
|
|
135
|
+
user_agent: str
|
|
136
|
+
viewport_width: int | None = None
|
|
137
|
+
product_id: str | None = None # set if on a product page
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ProductEvent(BaseEvent):
|
|
141
|
+
product_id: str
|
|
142
|
+
name: str = Field(..., min_length=1, max_length=500)
|
|
143
|
+
category: str
|
|
144
|
+
price: Decimal = Field(..., ge=0)
|
|
145
|
+
currency: Currency = Currency.USD
|
|
146
|
+
in_stock: bool
|
|
147
|
+
stock_quantity: int = Field(..., ge=0)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml # type: ignore[import-untyped]
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
yaml = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_tenants_config_path() -> Path:
|
|
16
|
+
return Path(os.getenv("AGENTFLOW_TENANTS_FILE", "config/tenants.yaml"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TenantDefinition(BaseModel):
|
|
20
|
+
id: str
|
|
21
|
+
display_name: str
|
|
22
|
+
kafka_topic_prefix: str
|
|
23
|
+
duckdb_schema: str
|
|
24
|
+
max_events_per_day: int
|
|
25
|
+
max_api_keys: int
|
|
26
|
+
allowed_entity_types: list[str] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TenantsConfig(BaseModel):
|
|
30
|
+
tenants: list[TenantDefinition] = Field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TenantRouter:
|
|
34
|
+
def __init__(self, config_path: Path | str | None = None) -> None:
|
|
35
|
+
self.config_path = (
|
|
36
|
+
Path(config_path) if config_path is not None else default_tenants_config_path()
|
|
37
|
+
)
|
|
38
|
+
self._has_config: bool | None = None
|
|
39
|
+
self._config: TenantsConfig | None = None
|
|
40
|
+
|
|
41
|
+
def has_config(self) -> bool:
|
|
42
|
+
if self._has_config is None:
|
|
43
|
+
self._has_config = self.config_path.exists()
|
|
44
|
+
return self._has_config
|
|
45
|
+
|
|
46
|
+
def load(self) -> TenantsConfig:
|
|
47
|
+
if self._config is not None:
|
|
48
|
+
return self._config
|
|
49
|
+
|
|
50
|
+
if not self.has_config():
|
|
51
|
+
self._config = TenantsConfig()
|
|
52
|
+
return self._config
|
|
53
|
+
|
|
54
|
+
raw = self.config_path.read_text(encoding="utf-8")
|
|
55
|
+
if yaml is not None:
|
|
56
|
+
data = yaml.safe_load(raw) or {}
|
|
57
|
+
else: # pragma: no cover
|
|
58
|
+
data = json.loads(raw)
|
|
59
|
+
self._config = TenantsConfig.model_validate(data)
|
|
60
|
+
return self._config
|
|
61
|
+
|
|
62
|
+
def get_tenant(self, tenant_id: str | None) -> TenantDefinition | None:
|
|
63
|
+
if tenant_id is None:
|
|
64
|
+
return None
|
|
65
|
+
for tenant in self.load().tenants:
|
|
66
|
+
if tenant.id == tenant_id:
|
|
67
|
+
return tenant
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def get_duckdb_schema(self, tenant_id: str | None) -> str | None:
|
|
71
|
+
tenant = self.get_tenant(tenant_id)
|
|
72
|
+
if tenant is None:
|
|
73
|
+
return None
|
|
74
|
+
return tenant.duckdb_schema
|
|
75
|
+
|
|
76
|
+
def route_topic(self, topic: str, tenant_id: str | None) -> str:
|
|
77
|
+
tenant = self.get_tenant(tenant_id)
|
|
78
|
+
if tenant is None:
|
|
79
|
+
return topic
|
|
80
|
+
return f"{tenant.kafka_topic_prefix}.{topic}"
|
src/logger.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import MutableMapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_otel_context(
|
|
13
|
+
_logger: Any,
|
|
14
|
+
_method_name: str,
|
|
15
|
+
event_dict: MutableMapping[str, Any],
|
|
16
|
+
) -> MutableMapping[str, Any]:
|
|
17
|
+
span = trace.get_current_span()
|
|
18
|
+
if not span.is_recording():
|
|
19
|
+
return event_dict
|
|
20
|
+
span_context = span.get_span_context()
|
|
21
|
+
event_dict["trace_id"] = format(span_context.trace_id, "032x")
|
|
22
|
+
event_dict["span_id"] = format(span_context.span_id, "016x")
|
|
23
|
+
return event_dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure_logging() -> None:
|
|
27
|
+
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
28
|
+
level = getattr(logging, level_name, logging.INFO)
|
|
29
|
+
logging.basicConfig(format="%(message)s", level=level)
|
|
30
|
+
structlog.configure(
|
|
31
|
+
processors=[
|
|
32
|
+
structlog.contextvars.merge_contextvars,
|
|
33
|
+
structlog.processors.add_log_level,
|
|
34
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
35
|
+
add_otel_context,
|
|
36
|
+
structlog.processors.JSONRenderer(),
|
|
37
|
+
],
|
|
38
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
39
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
40
|
+
cache_logger_on_first_use=True,
|
|
41
|
+
)
|
|
File without changes
|
|
File without changes
|