|
1 | 1 | import json |
2 | 2 | import logging |
| 3 | +import secrets |
3 | 4 | from datetime import date, datetime |
4 | 5 | from gzip import GzipFile |
5 | 6 | from io import BytesIO |
6 | 7 | from typing import Any, Optional, Union |
| 8 | +from uuid import uuid4 |
7 | 9 |
|
8 | 10 | import requests |
9 | 11 | from dateutil.tz import tzutc |
@@ -193,3 +195,166 @@ def default(self, obj: Any): |
193 | 195 | return obj.isoformat() |
194 | 196 |
|
195 | 197 | return json.JSONEncoder.default(self, obj) |
| 198 | + |
| 199 | + |
| 200 | +def build_ai_multipart_request( |
| 201 | + event_name: str, |
| 202 | + distinct_id: str, |
| 203 | + properties: dict[str, Any], |
| 204 | + blobs: dict[str, Any], |
| 205 | + timestamp: Optional[str] = None, |
| 206 | + event_uuid: Optional[str] = None, |
| 207 | +) -> tuple[bytes, str]: |
| 208 | + """ |
| 209 | + Build a multipart/form-data request body for AI events. |
| 210 | +
|
| 211 | + Args: |
| 212 | + event_name: The event name (e.g., "$ai_generation") |
| 213 | + distinct_id: The distinct ID for the event |
| 214 | + properties: Event properties (without blob properties) |
| 215 | + blobs: Dictionary of blob properties to include as separate parts |
| 216 | + timestamp: Optional timestamp for the event |
| 217 | + event_uuid: Optional UUID for the event |
| 218 | +
|
| 219 | + Returns: |
| 220 | + Tuple of (body_bytes, boundary) for the multipart request |
| 221 | +
|
| 222 | + Format follows the /i/v0/ai endpoint spec: |
| 223 | + Part 1: "event" - JSON with {uuid, event, distinct_id, timestamp} |
| 224 | + Part 2: "event.properties" - JSON with non-blob properties |
| 225 | + Part 3+: "event.properties.$ai_input" etc. - Blob data as JSON |
| 226 | + """ |
| 227 | + # Generate a random boundary that's unlikely to appear in the data |
| 228 | + boundary = "----WebKitFormBoundary" + secrets.token_hex(16) |
| 229 | + |
| 230 | + # Ensure we have a UUID |
| 231 | + if event_uuid is None: |
| 232 | + event_uuid = str(uuid4()) |
| 233 | + |
| 234 | + # Build the event part |
| 235 | + event_data = { |
| 236 | + "uuid": event_uuid, |
| 237 | + "event": event_name, |
| 238 | + "distinct_id": distinct_id, |
| 239 | + } |
| 240 | + if timestamp is not None: |
| 241 | + event_data["timestamp"] = timestamp |
| 242 | + |
| 243 | + # Build multipart body |
| 244 | + parts = [] |
| 245 | + |
| 246 | + # Part 1: event |
| 247 | + parts.append(f"--{boundary}\r\n".encode()) |
| 248 | + parts.append(b'Content-Disposition: form-data; name="event"\r\n') |
| 249 | + parts.append(b"Content-Type: application/json\r\n\r\n") |
| 250 | + parts.append(json.dumps(event_data, cls=DatetimeSerializer).encode("utf-8")) |
| 251 | + parts.append(b"\r\n") |
| 252 | + |
| 253 | + # Part 2: event.properties |
| 254 | + parts.append(f"--{boundary}\r\n".encode()) |
| 255 | + parts.append(b'Content-Disposition: form-data; name="event.properties"\r\n') |
| 256 | + parts.append(b"Content-Type: application/json\r\n\r\n") |
| 257 | + parts.append(json.dumps(properties, cls=DatetimeSerializer).encode("utf-8")) |
| 258 | + parts.append(b"\r\n") |
| 259 | + |
| 260 | + # Part 3+: blob parts |
| 261 | + for blob_name, blob_value in blobs.items(): |
| 262 | + parts.append(f"--{boundary}\r\n".encode()) |
| 263 | + parts.append( |
| 264 | + f'Content-Disposition: form-data; name="event.properties.{blob_name}"\r\n'.encode() |
| 265 | + ) |
| 266 | + parts.append(b"Content-Type: application/json\r\n\r\n") |
| 267 | + parts.append(json.dumps(blob_value, cls=DatetimeSerializer).encode("utf-8")) |
| 268 | + parts.append(b"\r\n") |
| 269 | + |
| 270 | + # Final boundary |
| 271 | + parts.append(f"--{boundary}--\r\n".encode()) |
| 272 | + |
| 273 | + # Combine all parts |
| 274 | + body = b"".join(parts) |
| 275 | + |
| 276 | + return body, boundary |
| 277 | + |
| 278 | + |
| 279 | +def ai_post( |
| 280 | + api_key: str, |
| 281 | + host: Optional[str] = None, |
| 282 | + gzip: bool = False, |
| 283 | + timeout: int = 15, |
| 284 | + **kwargs, |
| 285 | +) -> requests.Response: |
| 286 | + """ |
| 287 | + Post an AI event to the /i/v0/ai endpoint using multipart/form-data. |
| 288 | +
|
| 289 | + Args: |
| 290 | + api_key: The PostHog API key |
| 291 | + host: The host to post to |
| 292 | + gzip: Whether to gzip compress the request |
| 293 | + timeout: Request timeout in seconds |
| 294 | + **kwargs: Event parameters including event_name, distinct_id, properties, blobs, etc. |
| 295 | +
|
| 296 | + Returns: |
| 297 | + The response from the server |
| 298 | +
|
| 299 | + Raises: |
| 300 | + APIError: If the request fails |
| 301 | + """ |
| 302 | + log = logging.getLogger("posthog") |
| 303 | + |
| 304 | + # Extract event parameters |
| 305 | + event_name = kwargs.get("event_name") |
| 306 | + distinct_id = kwargs.get("distinct_id") |
| 307 | + properties = kwargs.get("properties", {}) |
| 308 | + blobs = kwargs.get("blobs", {}) |
| 309 | + timestamp = kwargs.get("timestamp") |
| 310 | + event_uuid = kwargs.get("uuid") |
| 311 | + |
| 312 | + # Build multipart request |
| 313 | + body, boundary = build_ai_multipart_request( |
| 314 | + event_name=event_name, |
| 315 | + distinct_id=distinct_id, |
| 316 | + properties=properties, |
| 317 | + blobs=blobs, |
| 318 | + timestamp=timestamp, |
| 319 | + event_uuid=event_uuid, |
| 320 | + ) |
| 321 | + |
| 322 | + # Optionally gzip compress the body if enabled and body is large enough |
| 323 | + # Spec recommends compression for requests > 10KB |
| 324 | + data = body |
| 325 | + headers = { |
| 326 | + "Content-Type": f"multipart/form-data; boundary={boundary}", |
| 327 | + "Authorization": f"Bearer {api_key}", |
| 328 | + "User-Agent": USER_AGENT, |
| 329 | + } |
| 330 | + |
| 331 | + if gzip or len(body) > 10 * 1024: # Compress if gzip enabled or body > 10KB |
| 332 | + headers["Content-Encoding"] = "gzip" |
| 333 | + buf = BytesIO() |
| 334 | + with GzipFile(fileobj=buf, mode="w") as gz: |
| 335 | + gz.write(body) |
| 336 | + data = buf.getvalue() |
| 337 | + log.debug("Compressed AI event from %d bytes to %d bytes", len(body), len(data)) |
| 338 | + |
| 339 | + url = remove_trailing_slash(host or DEFAULT_HOST) + "/i/v0/ai" |
| 340 | + log.debug("Posting AI event to %s", url) |
| 341 | + log.debug( |
| 342 | + "Event: %s, Distinct ID: %s, Blobs: %s", |
| 343 | + event_name, |
| 344 | + distinct_id, |
| 345 | + list(blobs.keys()), |
| 346 | + ) |
| 347 | + |
| 348 | + res = _session.post(url, data=data, headers=headers, timeout=timeout) |
| 349 | + |
| 350 | + if res.status_code == 200: |
| 351 | + log.debug("AI event uploaded successfully") |
| 352 | + return res |
| 353 | + |
| 354 | + # Handle errors |
| 355 | + try: |
| 356 | + payload = res.json() |
| 357 | + log.debug("Received error response: %s", payload) |
| 358 | + raise APIError(res.status_code, payload.get("detail", "Unknown error")) |
| 359 | + except (KeyError, ValueError): |
| 360 | + raise APIError(res.status_code, res.text) |
0 commit comments