amfs-http-server 0.1.0__tar.gz
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.
- amfs_http_server-0.1.0/.gitignore +16 -0
- amfs_http_server-0.1.0/PKG-INFO +14 -0
- amfs_http_server-0.1.0/pyproject.toml +27 -0
- amfs_http_server-0.1.0/src/amfs_http/__init__.py +1 -0
- amfs_http_server-0.1.0/src/amfs_http/auth.py +84 -0
- amfs_http_server-0.1.0/src/amfs_http/models.py +107 -0
- amfs_http_server-0.1.0/src/amfs_http/pro_proxy.py +253 -0
- amfs_http_server-0.1.0/src/amfs_http/server.py +2219 -0
- amfs_http_server-0.1.0/src/amfs_http/sse.py +56 -0
- amfs_http_server-0.1.0/src/amfs_http/tenant_middleware.py +43 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amfs-http-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AMFS HTTP/REST API server with SSE support
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: amfs
|
|
8
|
+
Requires-Dist: amfs-core
|
|
9
|
+
Requires-Dist: fastapi>=0.115
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: sse-starlette>=2.0
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
13
|
+
Provides-Extra: pro
|
|
14
|
+
Requires-Dist: amfs-patterns; extra == 'pro'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "amfs-http-server"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AMFS HTTP/REST API server with SSE support"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"amfs",
|
|
9
|
+
"amfs-core",
|
|
10
|
+
"fastapi>=0.115",
|
|
11
|
+
"uvicorn[standard]>=0.32",
|
|
12
|
+
"sse-starlette>=2.0",
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
pro = ["amfs-patterns"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
amfs-http = "amfs_http.server:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/amfs_http"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AMFS HTTP/REST API server — universal access to Agent Memory over HTTP."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""API key authentication for the AMFS HTTP server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Security, status
|
|
10
|
+
from fastapi.security import APIKeyHeader
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
API_KEY_HEADER = APIKeyHeader(name="X-AMFS-API-Key", auto_error=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_api_keys() -> set[str]:
|
|
18
|
+
raw = os.environ.get("AMFS_API_KEYS", "")
|
|
19
|
+
if not raw:
|
|
20
|
+
return set()
|
|
21
|
+
return {k.strip() for k in raw.split(",") if k.strip()}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check_db_key(api_key: str) -> bool:
|
|
25
|
+
"""Check if an API key exists in amfs_api_keys table (active keys only).
|
|
26
|
+
|
|
27
|
+
Keys are stored as SHA-256 hex digests, so we hash the incoming raw key
|
|
28
|
+
before comparing.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
import psycopg
|
|
32
|
+
from psycopg.rows import dict_row
|
|
33
|
+
|
|
34
|
+
dsn = os.environ.get("AMFS_POSTGRES_DSN")
|
|
35
|
+
if not dsn:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
with psycopg.connect(dsn, row_factory=dict_row) as conn:
|
|
41
|
+
row = conn.execute(
|
|
42
|
+
"SELECT id FROM amfs_api_keys "
|
|
43
|
+
"WHERE key_hash = %s AND active = TRUE "
|
|
44
|
+
"AND (expires_at IS NULL OR expires_at > NOW()) "
|
|
45
|
+
"LIMIT 1",
|
|
46
|
+
(key_hash,),
|
|
47
|
+
).fetchone()
|
|
48
|
+
if row:
|
|
49
|
+
conn.execute(
|
|
50
|
+
"UPDATE amfs_api_keys SET last_used = NOW() WHERE id = %s",
|
|
51
|
+
(row["id"],),
|
|
52
|
+
)
|
|
53
|
+
return row is not None
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.debug("DB API key check failed (table may not exist)", exc_info=True)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def generate_api_key() -> str:
|
|
60
|
+
return f"amfs_{secrets.token_urlsafe(32)}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def verify_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
|
64
|
+
env_keys = get_api_keys()
|
|
65
|
+
if not env_keys:
|
|
66
|
+
if not os.environ.get("AMFS_POSTGRES_DSN"):
|
|
67
|
+
return None
|
|
68
|
+
if api_key and _check_db_key(api_key):
|
|
69
|
+
return api_key
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if api_key and api_key in env_keys:
|
|
73
|
+
return api_key
|
|
74
|
+
|
|
75
|
+
if api_key and _check_db_key(api_key):
|
|
76
|
+
return api_key
|
|
77
|
+
|
|
78
|
+
if api_key is None and not env_keys:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
83
|
+
detail="Invalid or missing API key",
|
|
84
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Request and response models for the AMFS HTTP API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WriteRequest(BaseModel):
|
|
11
|
+
entity_path: str
|
|
12
|
+
key: str
|
|
13
|
+
value: Any = None
|
|
14
|
+
confidence: float = 1.0
|
|
15
|
+
pattern_refs: list[str] = Field(default_factory=list)
|
|
16
|
+
memory_type: str = "fact"
|
|
17
|
+
shared: bool = True
|
|
18
|
+
branch: str = "main"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OutcomeRequest(BaseModel):
|
|
22
|
+
outcome_ref: str
|
|
23
|
+
outcome_type: str
|
|
24
|
+
causal_entry_keys: list[str] | None = None
|
|
25
|
+
causal_confidence: float = 1.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SearchRequest(BaseModel):
|
|
29
|
+
query: str | None = None
|
|
30
|
+
entity_path: str | None = None
|
|
31
|
+
min_confidence: float = 0.0
|
|
32
|
+
max_confidence: float | None = None
|
|
33
|
+
agent_id: str | None = None
|
|
34
|
+
since: datetime | None = None
|
|
35
|
+
pattern_ref: str | None = None
|
|
36
|
+
limit: int = 100
|
|
37
|
+
sort_by: str = "confidence"
|
|
38
|
+
branch: str = "main"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ContextRequest(BaseModel):
|
|
42
|
+
label: str
|
|
43
|
+
summary: str
|
|
44
|
+
source: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CreateAPIKeyRequest(BaseModel):
|
|
48
|
+
name: str
|
|
49
|
+
key_type: str = "agent"
|
|
50
|
+
scopes: list[dict[str, str]] = Field(default_factory=lambda: [{"pattern": "*", "permission": "read_write"}])
|
|
51
|
+
rate_limit_rpm: int = 120
|
|
52
|
+
expires_at: datetime | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
# Teams (Pro)
|
|
57
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CreateTeamRequest(BaseModel):
|
|
61
|
+
name: str
|
|
62
|
+
slug: str
|
|
63
|
+
description: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UpdateTeamRequest(BaseModel):
|
|
67
|
+
name: str | None = None
|
|
68
|
+
description: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AddTeamMemberRequest(BaseModel):
|
|
72
|
+
email: str
|
|
73
|
+
display_name: str = ""
|
|
74
|
+
role: str = "developer"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UpdateTeamMemberRequest(BaseModel):
|
|
78
|
+
role: str | None = None
|
|
79
|
+
display_name: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
83
|
+
# Patterns (Pro)
|
|
84
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RunPatternDetectionRequest(BaseModel):
|
|
88
|
+
incident_threshold: int = 2
|
|
89
|
+
stale_days: int = 30
|
|
90
|
+
hot_entity_stddev: float = 2.0
|
|
91
|
+
drift_stddev: float = 2.0
|
|
92
|
+
entity_path: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
96
|
+
# Events (Shared Pool Ingestion)
|
|
97
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EventRequest(BaseModel):
|
|
101
|
+
"""Direct shared-pool event ingestion without connector framework."""
|
|
102
|
+
|
|
103
|
+
source: str
|
|
104
|
+
entity_path: str
|
|
105
|
+
key: str
|
|
106
|
+
value: Any = None
|
|
107
|
+
event_type: str = "generic"
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Pro SaaS proxy — forwards Pro feature requests to the hosted Pro API.
|
|
2
|
+
|
|
3
|
+
When AMFS_PRO_URL and AMFS_PRO_API_KEY are configured, this module:
|
|
4
|
+
1. Mounts proxy routes for hot-context and anticipation endpoints
|
|
5
|
+
2. Provides an async event forwarder for the CortexWorker
|
|
6
|
+
3. Falls back gracefully when the Pro API is unreachable
|
|
7
|
+
|
|
8
|
+
Customer flow:
|
|
9
|
+
Dashboard → OSS HTTP Server (proxy) → Pro SaaS API
|
|
10
|
+
CortexWorker → Pro SaaS API (event forwarding)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
PRO_URL = os.environ.get("AMFS_PRO_URL", "").rstrip("/")
|
|
25
|
+
PRO_KEY = os.environ.get("AMFS_PRO_API_KEY", "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_pro_configured() -> bool:
|
|
29
|
+
return bool(PRO_URL and PRO_KEY)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pro_headers() -> dict[str, str]:
|
|
33
|
+
return {
|
|
34
|
+
"Authorization": f"Bearer {PRO_KEY}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Route proxying — mount onto the FastAPI app
|
|
41
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mount_pro_proxy(app) -> None:
|
|
45
|
+
"""Mount proxy routes that forward Pro requests to the SaaS API."""
|
|
46
|
+
if not is_pro_configured():
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
import httpx
|
|
50
|
+
from fastapi import Depends, Query
|
|
51
|
+
|
|
52
|
+
_client = httpx.AsyncClient(timeout=10.0)
|
|
53
|
+
|
|
54
|
+
logger.info("Pro SaaS proxy enabled → %s", PRO_URL)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from amfs_http.auth import verify_api_key
|
|
58
|
+
except ImportError:
|
|
59
|
+
async def verify_api_key():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@app.get("/api/v1/cortex/hot-context")
|
|
63
|
+
async def proxy_hot_context(
|
|
64
|
+
agent_id: str | None = Query(None),
|
|
65
|
+
_auth: str | None = Depends(verify_api_key),
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Proxy hot-context request to Pro SaaS API."""
|
|
68
|
+
params = {}
|
|
69
|
+
if agent_id:
|
|
70
|
+
params["agent_id"] = agent_id
|
|
71
|
+
try:
|
|
72
|
+
resp = await _client.get(
|
|
73
|
+
f"{PRO_URL}/api/v1/pro/cortex/hot-context",
|
|
74
|
+
params=params,
|
|
75
|
+
headers=_pro_headers(),
|
|
76
|
+
)
|
|
77
|
+
return resp.json()
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.debug("Pro API unreachable for hot-context", exc_info=True)
|
|
80
|
+
return {"agent_id": agent_id, "status": "unavailable", "focus_entities": [], "last_read_entities": []}
|
|
81
|
+
|
|
82
|
+
@app.get("/api/v1/cortex/anticipation")
|
|
83
|
+
async def proxy_anticipation(
|
|
84
|
+
entity_path: str | None = Query(None),
|
|
85
|
+
limit: int = Query(default=20, le=100),
|
|
86
|
+
_auth: str | None = Depends(verify_api_key),
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Fetch local digests, send to Pro SaaS for anticipation ranking."""
|
|
89
|
+
try:
|
|
90
|
+
from amfs_http.server import _cortex_worker
|
|
91
|
+
if _cortex_worker:
|
|
92
|
+
adapter = _cortex_worker._compiler._adapter
|
|
93
|
+
all_digests = adapter.list_digests()
|
|
94
|
+
digest_dicts = [d.model_dump(mode="json") for d in all_digests]
|
|
95
|
+
else:
|
|
96
|
+
digest_dicts = []
|
|
97
|
+
|
|
98
|
+
resp = await _client.post(
|
|
99
|
+
f"{PRO_URL}/api/v1/pro/cortex/rank",
|
|
100
|
+
headers=_pro_headers(),
|
|
101
|
+
json={
|
|
102
|
+
"digests": digest_dicts,
|
|
103
|
+
"entity_path": entity_path,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
data = resp.json()
|
|
107
|
+
ranked = data.get("digests", [])[:limit]
|
|
108
|
+
return {"digests": ranked, "total": data.get("total", 0)}
|
|
109
|
+
except Exception:
|
|
110
|
+
logger.debug("Pro API unreachable for anticipation", exc_info=True)
|
|
111
|
+
return {"digests": [], "message": "Pro API unavailable"}
|
|
112
|
+
|
|
113
|
+
# --- Trace Pro actions (verify, replay, diff) ---
|
|
114
|
+
|
|
115
|
+
@app.post("/api/v1/pro/traces/{trace_id}/verify")
|
|
116
|
+
async def proxy_verify_trace(
|
|
117
|
+
trace_id: str,
|
|
118
|
+
_auth: str | None = Depends(verify_api_key),
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""Proxy trace verification to Pro SaaS API."""
|
|
121
|
+
try:
|
|
122
|
+
resp = await _client.post(
|
|
123
|
+
f"{PRO_URL}/api/v1/pro/traces/{trace_id}/verify",
|
|
124
|
+
headers=_pro_headers(),
|
|
125
|
+
)
|
|
126
|
+
if resp.status_code == 404:
|
|
127
|
+
from fastapi import HTTPException
|
|
128
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
129
|
+
return resp.json()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
if hasattr(exc, "status_code"):
|
|
132
|
+
raise
|
|
133
|
+
logger.debug("Pro API unreachable for trace verify", exc_info=True)
|
|
134
|
+
return {"error": "Pro API unavailable"}
|
|
135
|
+
|
|
136
|
+
@app.post("/api/v1/pro/traces/{trace_id}/replay")
|
|
137
|
+
async def proxy_replay_trace(
|
|
138
|
+
trace_id: str,
|
|
139
|
+
_auth: str | None = Depends(verify_api_key),
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Proxy trace replay to Pro SaaS API."""
|
|
142
|
+
try:
|
|
143
|
+
resp = await _client.post(
|
|
144
|
+
f"{PRO_URL}/api/v1/pro/traces/{trace_id}/replay",
|
|
145
|
+
headers=_pro_headers(),
|
|
146
|
+
)
|
|
147
|
+
if resp.status_code == 404:
|
|
148
|
+
from fastapi import HTTPException
|
|
149
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
150
|
+
return resp.json()
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
if hasattr(exc, "status_code"):
|
|
153
|
+
raise
|
|
154
|
+
logger.debug("Pro API unreachable for trace replay", exc_info=True)
|
|
155
|
+
return {"error": "Pro API unavailable"}
|
|
156
|
+
|
|
157
|
+
@app.post("/api/v1/pro/traces/{trace_id}/diff")
|
|
158
|
+
async def proxy_diff_trace(
|
|
159
|
+
trace_id: str,
|
|
160
|
+
_auth: str | None = Depends(verify_api_key),
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
"""Proxy trace diff to Pro SaaS API."""
|
|
163
|
+
try:
|
|
164
|
+
resp = await _client.post(
|
|
165
|
+
f"{PRO_URL}/api/v1/pro/traces/{trace_id}/diff",
|
|
166
|
+
headers=_pro_headers(),
|
|
167
|
+
)
|
|
168
|
+
if resp.status_code == 404:
|
|
169
|
+
from fastapi import HTTPException
|
|
170
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
171
|
+
return resp.json()
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
if hasattr(exc, "status_code"):
|
|
174
|
+
raise
|
|
175
|
+
logger.debug("Pro API unreachable for trace diff", exc_info=True)
|
|
176
|
+
return {"error": "Pro API unavailable"}
|
|
177
|
+
|
|
178
|
+
logger.info("Pro proxy routes mounted: hot-context, anticipation, trace-verify, trace-replay, trace-diff")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
182
|
+
# Event forwarding — used by CortexWorker to push events to Pro SaaS
|
|
183
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ProEventForwarder:
|
|
187
|
+
"""Async-safe event forwarder for the CortexWorker.
|
|
188
|
+
|
|
189
|
+
Buffers events and sends them in batches to the Pro SaaS API.
|
|
190
|
+
Runs a background thread with its own event loop so it works
|
|
191
|
+
from the synchronous CortexWorker.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(self, batch_size: int = 10, flush_interval: float = 2.0) -> None:
|
|
195
|
+
self._buffer: list[dict[str, Any]] = []
|
|
196
|
+
self._lock = threading.Lock()
|
|
197
|
+
self._batch_size = batch_size
|
|
198
|
+
self._flush_interval = flush_interval
|
|
199
|
+
self._stop = threading.Event()
|
|
200
|
+
self._thread: threading.Thread | None = None
|
|
201
|
+
|
|
202
|
+
def start(self) -> None:
|
|
203
|
+
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="pro-event-forwarder")
|
|
204
|
+
self._thread.start()
|
|
205
|
+
logger.info("Pro event forwarder started → %s", PRO_URL)
|
|
206
|
+
|
|
207
|
+
def stop(self) -> None:
|
|
208
|
+
self._stop.set()
|
|
209
|
+
|
|
210
|
+
def enqueue(self, event: dict[str, Any]) -> None:
|
|
211
|
+
batch = None
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._buffer.append(event)
|
|
214
|
+
if len(self._buffer) >= self._batch_size:
|
|
215
|
+
batch = self._buffer[:]
|
|
216
|
+
self._buffer.clear()
|
|
217
|
+
if batch:
|
|
218
|
+
self._send_batch(batch)
|
|
219
|
+
|
|
220
|
+
def _run_loop(self) -> None:
|
|
221
|
+
while not self._stop.is_set():
|
|
222
|
+
self._stop.wait(self._flush_interval)
|
|
223
|
+
with self._lock:
|
|
224
|
+
if not self._buffer:
|
|
225
|
+
continue
|
|
226
|
+
batch = self._buffer[:]
|
|
227
|
+
self._buffer.clear()
|
|
228
|
+
self._send_batch(batch)
|
|
229
|
+
|
|
230
|
+
def _send_batch(self, batch: list[dict[str, Any]]) -> None:
|
|
231
|
+
if not batch:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
import urllib.request
|
|
235
|
+
req = urllib.request.Request(
|
|
236
|
+
f"{PRO_URL}/api/v1/pro/cortex/ingest",
|
|
237
|
+
data=json.dumps({"events": batch}).encode(),
|
|
238
|
+
headers=_pro_headers(),
|
|
239
|
+
method="POST",
|
|
240
|
+
)
|
|
241
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
242
|
+
logger.debug("Forwarded %d events to Pro API (status=%d)", len(batch), resp.status)
|
|
243
|
+
except Exception:
|
|
244
|
+
logger.debug("Failed to forward %d events to Pro API", len(batch), exc_info=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def create_forwarder() -> ProEventForwarder | None:
|
|
248
|
+
"""Create and start a Pro event forwarder if Pro is configured."""
|
|
249
|
+
if not is_pro_configured():
|
|
250
|
+
return None
|
|
251
|
+
forwarder = ProEventForwarder()
|
|
252
|
+
forwarder.start()
|
|
253
|
+
return forwarder
|