feat: Playground refactor + add observability (#32625)

This commit is contained in:
Peter Kirkham
2025-05-25 09:13:24 -07:00
committed by GitHub
parent 1b359e6b42
commit e3e2c41b4d
40 changed files with 154 additions and 76 deletions

View File

@@ -4,7 +4,7 @@ from rest_framework_extensions.routers import NestedRegistryItem
import products.data_warehouse.backend.api.fix_hogql as fix_hogql
import products.early_access_features.backend.api as early_access_feature
from products.user_interviews.backend.api import UserInterviewViewSet
from products.editor.backend.api import LLMProxyViewSet, MaxToolsViewSet
from products.llm_observability.api import LLMProxyViewSet, MaxToolsViewSet
from products.messaging.backend.api import MessageTemplatesViewSet
import products.logs.backend.api as logs
from posthog.api import data_color_theme, metalytics, project, wizard

View File

@@ -94,8 +94,8 @@
'/home/runner/work/posthog/posthog/posthog/session_recordings/session_recording_playlist_api.py: Warning [SessionRecordingPlaylistViewSet]: could not derive type of path parameter "session_recording_id" because model "posthog.session_recordings.models.session_recording_playlist.SessionRecordingPlaylist" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/taxonomy/property_definition_api.py: Warning [PropertyDefinitionViewSet > PropertyDefinitionSerializer]: unable to resolve type hint for function "is_seen_on_filtered_events". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/products/early_access_features/backend/api.py: Warning [EarlyAccessFeatureViewSet]: could not derive type of path parameter "project_id" because model "products.early_access_features.backend.models.EarlyAccessFeature" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
"/home/runner/work/posthog/posthog/products/editor/backend/api/max_tools.py: Error [MaxToolsViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'MaxToolsViewSet' should either include a `serializer_class` attribute, or override the `get_serializer_class()` method.)",
'/home/runner/work/posthog/posthog/products/editor/backend/api/max_tools.py: Warning [MaxToolsViewSet]: could not derive type of path parameter "project_id" because model "ee.models.assistant.Conversation" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
"/home/runner/work/posthog/posthog/products/llm_observability/api/max_tools.py: Error [MaxToolsViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'MaxToolsViewSet' should either include a `serializer_class` attribute, or override the `get_serializer_class()` method.)",
'/home/runner/work/posthog/posthog/products/llm_observability/api/max_tools.py: Warning [MaxToolsViewSet]: could not derive type of path parameter "project_id" because model "ee.models.assistant.Conversation" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Error [LogsViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.',
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Warning [LogsViewSet]: could not derive type of path parameter "project_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:project_id>) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/products/user_interviews/backend/api.py: Warning [UserInterviewViewSet]: could not derive type of path parameter "project_id" because model "products.user_interviews.backend.models.UserInterview" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',

View File

@@ -321,15 +321,15 @@ class AISustainedRateThrottle(UserRateThrottle):
rate = "40/day"
class EditorProxyBurstRateThrottle(UserRateThrottle):
scope = "editor_proxy_burst"
class LLMProxyBurstRateThrottle(UserRateThrottle):
scope = "llm_proxy_burst"
rate = "30/minute"
class EditorProxySustainedRateThrottle(UserRateThrottle):
class LLMProxySustainedRateThrottle(UserRateThrottle):
# Throttle class that's very aggressive and is used specifically on endpoints that hit OpenAI
# Intended to block slower but sustained bursts of requests, per user
scope = "editor_proxy_sustained"
scope = "llm_proxy_sustained"
rate = "500/hour"

View File

@@ -29,7 +29,6 @@ AXES_META_PRECEDENCE_ORDER = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"]
# NOTE: Add these definitions here and on `tach.toml`
PRODUCTS_APPS = [
"products.early_access_features",
"products.editor",
"products.links",
"products.revenue_analytics",
"products.user_interviews",

View File

@@ -1,4 +0,0 @@
{
"name": "@posthog/editor",
"peerDependencies": {}
}

View File

@@ -0,0 +1 @@

View File

@@ -16,8 +16,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.auth import PersonalAPIKeyAuthentication
from posthog.models.user import User
from posthog.rate_limit import AIBurstRateThrottle, AISustainedRateThrottle
from posthog.renderers import SafeJSONRenderer
from products.editor.backend.api.proxy import ServerSentEventRenderer
from posthog.renderers import SafeJSONRenderer, ServerSentEventRenderer
class InsightsToolCallSerializer(serializers.Serializer):

View File

@@ -1,5 +1,5 @@
"""
ViewSet for Editor Proxy
ViewSet for LLM Observability Proxy
Endpoints:
- GET /api/llm_proxy/models
@@ -9,6 +9,7 @@ Endpoints:
import json
import posthoganalytics
import uuid
from rest_framework import viewsets
from posthog.auth import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
@@ -17,13 +18,13 @@ from rest_framework.decorators import action
from rest_framework.request import Request
from django.http import StreamingHttpResponse
from rest_framework.response import Response
from posthog.rate_limit import EditorProxyBurstRateThrottle, EditorProxySustainedRateThrottle
from posthog.rate_limit import LLMProxyBurstRateThrottle, LLMProxySustainedRateThrottle
from posthog.renderers import SafeJSONRenderer, ServerSentEventRenderer
from products.editor.backend.providers.anthropic import AnthropicProvider, AnthropicConfig
from products.editor.backend.providers.openai import OpenAIProvider, OpenAIConfig
from products.editor.backend.providers.codestral import CodestralProvider, CodestralConfig
from products.editor.backend.providers.inkeep import InkeepProvider, InkeepConfig
from products.editor.backend.providers.gemini import GeminiProvider, GeminiConfig
from products.llm_observability.providers.anthropic import AnthropicProvider, AnthropicConfig
from products.llm_observability.providers.openai import OpenAIProvider, OpenAIConfig
from products.llm_observability.providers.codestral import CodestralProvider, CodestralConfig
from products.llm_observability.providers.inkeep import InkeepProvider, InkeepConfig
from products.llm_observability.providers.gemini import GeminiProvider, GeminiConfig
from posthog.settings import SERVER_GATEWAY_INTERFACE
from ee.hogai.utils.asgi import SyncIterableToAsync
from collections.abc import Generator, Callable
@@ -64,8 +65,8 @@ class ProviderData(TypedDict):
class LLMProxyViewSet(viewsets.ViewSet):
"""
ViewSet for Editor Proxy
Proxies LLM calls from the editor
ViewSet for LLM Observability Proxy
Proxies LLM calls from the llm observability playground
"""
authentication_classes = [SessionAuthentication]
@@ -73,16 +74,14 @@ class LLMProxyViewSet(viewsets.ViewSet):
renderer_classes = [SafeJSONRenderer, ServerSentEventRenderer]
def get_throttles(self):
return [EditorProxyBurstRateThrottle(), EditorProxySustainedRateThrottle()]
return [LLMProxyBurstRateThrottle(), LLMProxySustainedRateThrottle()]
def validate_feature_flag(self, request):
result_session = SessionAuthentication().authenticate(request)
if result_session is not None:
user, _ = result_session
else:
if not request.user or not request.user.is_authenticated:
return False
llm_observability_enabled = posthoganalytics.feature_enabled(
"llm-observability-playground", user.email, person_properties={"email": user.email}
"llm-observability-playground", request.user.email, person_properties={"email": request.user.email}
)
return llm_observability_enabled
@@ -139,6 +138,16 @@ class LLMProxyViewSet(viewsets.ViewSet):
if isinstance(provider, Response): # Error response
return provider
# Generate tracking parameters for PostHog observability
trace_id = str(uuid.uuid4())
distinct_id = getattr(request.user, "email", "") if request.user and request.user.is_authenticated else ""
properties = {"ai_product": "playground"}
groups = {} # placeholder for groups, maybe we should add team_id here or something????
if request.user and request.user.is_authenticated:
team_id = getattr(request.user, "team_id", None)
if team_id:
groups["team"] = str(team_id)
if mode == "completion" and hasattr(provider, "stream_response"):
messages = serializer.validated_data.get("messages")
if not self.validate_messages(messages):
@@ -151,6 +160,10 @@ class LLMProxyViewSet(viewsets.ViewSet):
"thinking": serializer.validated_data.get("thinking", False),
"temperature": serializer.validated_data.get("temperature"),
"max_tokens": serializer.validated_data.get("max_tokens"),
"distinct_id": distinct_id,
"trace_id": trace_id,
"properties": properties,
"groups": groups,
}
),
request,
@@ -164,6 +177,10 @@ class LLMProxyViewSet(viewsets.ViewSet):
"stop": serializer.validated_data.get("stop"),
"temperature": serializer.validated_data.get("temperature"),
"max_tokens": serializer.validated_data.get("max_tokens"),
"distinct_id": distinct_id,
"trace_id": trace_id,
"properties": properties,
"groups": groups,
}
),
request,
@@ -211,10 +228,10 @@ class LLMProxyViewSet(viewsets.ViewSet):
"""Return a list of available models across providers"""
model_list: list[dict[str, str]] = []
model_list += [
{"id": m, "name": m, "provider": "Anthropic", "description": ""} for m in AnthropicConfig.SUPPORTED_MODELS
{"id": m, "name": m, "provider": "OpenAI", "description": ""} for m in OpenAIConfig.SUPPORTED_MODELS
]
model_list += [
{"id": m, "name": m, "provider": "OpenAI", "description": ""} for m in OpenAIConfig.SUPPORTED_MODELS
{"id": m, "name": m, "provider": "Anthropic", "description": ""} for m in AnthropicConfig.SUPPORTED_MODELS
]
model_list += [
{"id": m, "name": m, "provider": "Gemini", "description": ""} for m in GeminiConfig.SUPPORTED_MODELS

View File

@@ -1,5 +1,5 @@
from posthog.test.base import BaseTest
from products.editor.backend.chunking import ProgrammingLanguage, chunk_text
from products.llm_observability.chunking import ProgrammingLanguage, chunk_text
from .util import load_fixture

View File

@@ -1,5 +1,5 @@
from posthog.test.base import BaseTest
from products.editor.backend.chunking import chunk_text
from products.llm_observability.chunking import chunk_text
from .util import load_fixture

View File

@@ -1,9 +1,11 @@
import json
from collections.abc import Generator
from django.conf import settings
import anthropic
import posthoganalytics
from posthoganalytics.ai.anthropic import Anthropic
from anthropic.types import MessageParam, TextBlockParam, ThinkingConfigEnabledParam
import logging
import uuid
logger = logging.getLogger(__name__)
@@ -43,7 +45,11 @@ class AnthropicConfig:
class AnthropicProvider:
def __init__(self, model_id: str):
self.client = anthropic.Anthropic(api_key=self.get_api_key())
posthog_client = posthoganalytics.default_client
if not posthog_client:
raise ValueError("PostHog client not found")
self.client = Anthropic(api_key=self.get_api_key(), posthog_client=posthog_client)
self.validate_model(model_id)
self.model_id = model_id
@@ -105,6 +111,10 @@ class AnthropicProvider:
thinking: bool = False,
temperature: float | None = None,
max_tokens: int | None = None,
distinct_id: str = "",
trace_id: str | None = None,
properties: dict | None = None,
groups: dict | None = None,
) -> Generator[str, None]:
"""
Async generator function that yields SSE formatted data
@@ -128,9 +138,10 @@ class AnthropicProvider:
else:
system_prompt = [TextBlockParam(**{"text": system, "type": "text", "cache_control": None})]
formatted_messages = [MessageParam(content=msg["content"], role=msg["role"]) for msg in messages]
try:
if reasoning_on:
stream = self.client.messages.create(
stream = self.client.messages.create( # type: ignore[call-overload]
messages=formatted_messages,
max_tokens=effective_max_tokens,
model=self.model_id,
@@ -140,24 +151,28 @@ class AnthropicProvider:
thinking=ThinkingConfigEnabledParam(
type="enabled", budget_tokens=AnthropicConfig.MAX_THINKING_TOKENS
),
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id or str(uuid.uuid4()),
posthog_properties={**(properties or {}), "ai_product": "playground"},
posthog_groups=groups or {},
)
else:
stream = self.client.messages.create(
stream = self.client.messages.create( # type: ignore[call-overload]
messages=formatted_messages,
max_tokens=effective_max_tokens,
model=self.model_id,
system=system_prompt,
stream=True,
temperature=effective_temperature,
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id or str(uuid.uuid4()),
posthog_properties={**(properties or {}), "ai_product": "playground"},
posthog_groups=groups or {},
)
except anthropic.APIError as e:
except Exception as e:
logger.exception(f"Anthropic API error: {e}")
yield f"data: {json.dumps({'type': 'error', 'error': f'Anthropic API error'})}\n\n"
return
except Exception as e:
logger.exception(f"Unexpected error: {e}")
yield f"data: {json.dumps({'type': 'error', 'error': f'Unexpected error'})}\n\n"
return
for chunk in stream:
if chunk.type == "message_start":

View File

@@ -7,6 +7,8 @@ import json
from collections.abc import Generator
from django.conf import settings
import mistralai
import posthoganalytics
import uuid
logger = logging.getLogger(__name__)
@@ -44,6 +46,10 @@ class CodestralProvider:
stop: list[str],
temperature: float | None = None,
max_tokens: int | None = None,
distinct_id: str = "",
trace_id: str | None = None,
properties: dict | None = None,
groups: dict | None = None,
) -> Generator[str, None, None]:
"""
Generator function that yields SSE formatted data
@@ -53,6 +59,21 @@ class CodestralProvider:
effective_temperature = temperature if temperature is not None else CodestralConfig.TEMPERATURE
effective_max_tokens = max_tokens if max_tokens is not None else CodestralConfig.MAX_TOKENS
# Manually track with PostHog since Codestral doesn't have native support
posthoganalytics.capture(
distinct_id=distinct_id,
event="$ai_generation",
properties={
**(properties or {}),
"ai_product": "playground",
"provider": "codestral",
"model": self.model_id,
"trace_id": trace_id or str(uuid.uuid4()),
"mode": "fim",
},
groups=groups,
)
response = self.client.fim.stream(
model=self.model_id,
prompt=prompt,

View File

@@ -2,7 +2,7 @@ import base64
from anthropic.types import MessageParam
from google.genai.types import Part, Content, Blob, ContentListUnion
from typing import cast
from products.editor.backend.providers.formatters.anthropic_typeguards import (
from products.llm_observability.providers.formatters.anthropic_typeguards import (
is_base64_image_param,
is_image_block_param,
is_text_block_param,

View File

@@ -10,7 +10,7 @@ from openai.types.chat import (
ChatCompletionMessageToolCallParam,
)
from products.editor.backend.providers.formatters.anthropic_typeguards import (
from products.llm_observability.providers.formatters.anthropic_typeguards import (
is_base64_image_param,
is_image_block_param,
is_text_block_param,

View File

@@ -1,14 +1,16 @@
import google.genai as genai
from posthoganalytics.ai.gemini import genai
from google.genai.types import GenerateContentConfig
from google.genai.errors import APIError
import json
from collections.abc import Generator
from django.conf import settings
import posthoganalytics
from anthropic.types import MessageParam
import logging
import uuid
from products.editor.backend.providers.formatters.gemini_formatter import convert_anthropic_messages_to_gemini
from products.llm_observability.providers.formatters.gemini_formatter import convert_anthropic_messages_to_gemini
logger = logging.getLogger(__name__)
@@ -29,7 +31,11 @@ class GeminiConfig:
class GeminiProvider:
def __init__(self, model_id: str):
self.client = genai.Client(api_key=self.get_api_key())
posthog_client = posthoganalytics.default_client
if not posthog_client:
raise ValueError("PostHog client not found")
self.client = genai.Client(api_key=self.get_api_key(), posthog_client=posthog_client)
self.validate_model(model_id)
self.model_id = model_id
@@ -51,6 +57,10 @@ class GeminiProvider:
thinking: bool = False,
temperature: float | None = None,
max_tokens: int | None = None,
distinct_id: str = "",
trace_id: str | None = None,
properties: dict | None = None,
groups: dict | None = None,
) -> Generator[str, None]:
"""
Async generator function that yields SSE formatted data
@@ -73,7 +83,12 @@ class GeminiProvider:
model=self.model_id,
contents=convert_anthropic_messages_to_gemini(messages),
config=GenerateContentConfig(**config_kwargs),
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id or str(uuid.uuid4()),
posthog_properties={**(properties or {}), "ai_product": "playground"},
posthog_groups=groups or {},
)
for chunk in response:
if chunk.text:
yield f"data: {json.dumps({'type': 'text', 'text': chunk.text})}\n\n"

View File

@@ -5,9 +5,11 @@ from django.conf import settings
import openai
from anthropic.types import MessageParam
import logging
import posthoganalytics
import uuid
from typing import Any
from products.editor.backend.providers.formatters.openai_formatter import convert_to_openai_messages
from products.llm_observability.providers.formatters.openai_formatter import convert_to_openai_messages
logger = logging.getLogger(__name__)
@@ -37,12 +39,30 @@ class InkeepProvider:
thinking: bool = False,
temperature: float | None = None,
max_tokens: int | None = None,
distinct_id: str = "",
trace_id: str | None = None,
properties: dict | None = None,
groups: dict | None = None,
) -> Generator[str, None, None]:
"""
Generator function that yields SSE formatted data
"""
try:
# Manually track with PostHog since Inkeep doesn't have native support
posthoganalytics.capture(
distinct_id=distinct_id,
event="$ai_generation",
properties={
**(properties or {}),
"ai_product": "playground",
"provider": "inkeep",
"model": self.model_id,
"trace_id": trace_id or str(uuid.uuid4()),
},
groups=groups,
)
kwargs: dict[str, Any] = {
"model": self.model_id,
"stream": True,

View File

@@ -1,7 +1,8 @@
import json
from collections.abc import Generator
from django.conf import settings
import openai
import posthoganalytics
from posthoganalytics.ai.openai import OpenAI
from anthropic.types import MessageParam
from openai.types import ReasoningEffort, CompletionUsage
from openai.types.chat import (
@@ -9,9 +10,10 @@ from openai.types.chat import (
ChatCompletionSystemMessageParam,
)
import logging
import uuid
from typing import Any
from products.editor.backend.providers.formatters.openai_formatter import convert_to_openai_messages
from products.llm_observability.providers.formatters.openai_formatter import convert_to_openai_messages
logger = logging.getLogger(__name__)
@@ -47,7 +49,11 @@ class OpenAIConfig:
class OpenAIProvider:
def __init__(self, model_id: str):
self.client = openai.OpenAI(api_key=self.get_api_key())
posthog_client = posthoganalytics.default_client
if not posthog_client:
raise ValueError("PostHog client not found")
self.client = OpenAI(api_key=self.get_api_key(), posthog_client=posthog_client)
self.validate_model(model_id)
self.model_id = model_id
@@ -82,6 +88,10 @@ class OpenAIProvider:
thinking: bool = False,
temperature: float | None = None,
max_tokens: int | None = None,
distinct_id: str = "",
trace_id: str | None = None,
properties: dict | None = None,
groups: dict | None = None,
) -> Generator[str, None]:
"""
Async generator function that yields SSE formatted data
@@ -101,6 +111,10 @@ class OpenAIProvider:
"stream": True,
"stream_options": {"include_usage": True},
"temperature": effective_temperature,
"posthog_distinct_id": distinct_id,
"posthog_trace_id": trace_id or str(uuid.uuid4()),
"posthog_properties": {**(properties or {}), "ai_product": "playground"},
"posthog_groups": groups or {},
}
if max_tokens is not None:
common["max_tokens"] = max_tokens
@@ -143,11 +157,7 @@ class OpenAIProvider:
if chunk.usage:
yield from self.yield_usage(chunk.usage)
except openai.APIError as e:
except Exception as e:
logger.exception(f"OpenAI API error: {e}")
yield f"data: {json.dumps({'type': 'error', 'error': f'OpenAI API error'})}\n\n"
return
except Exception as e:
logger.exception(f"Unexpected error: {e}")
yield f"data: {json.dumps({'type': 'error', 'error': f'Unexpected error'})}\n\n"
return

View File

@@ -79,7 +79,7 @@ dependencies = [
"pdpyras==5.2.0",
"phonenumberslite==8.13.6",
"pillow==10.2.0",
"posthoganalytics>=3.24.1",
"posthoganalytics>=4.2.0",
"psutil==6.0.0",
"psycopg2-binary==2.9.7",
"psycopg[binary]==3.2.4",

View File

@@ -28,7 +28,6 @@ depends_on = [
# NOTE: Add new product dependencies here and in settings/web.py PRODUCTS_APPS
"products.early_access_features",
"products.editor",
"products.links",
"products.revenue_analytics",
"products.user_interviews",
@@ -38,10 +37,6 @@ depends_on = [
path = "products.early_access_features"
depends_on = ["posthog"]
[[modules]]
path = "products.editor"
depends_on = ["posthog"]
[[modules]]
path = "products.links"
depends_on = ["posthog"]

18
uv.lock generated
View File

@@ -3129,15 +3129,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/b2/7bbf5e3607b04e745548dd8c17863700ca77746ab5e0cfdcac83a74d7afc/mistralai-1.6.0-py3-none-any.whl", hash = "sha256:6a4f4d6b5c9fff361741aa5513cd2917a81be520deeb0d33e963d1c31eae8c19", size = 288701 },
]
[[package]]
name = "monotonic"
version = "1.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/ca/8e91948b782ddfbd194f323e7e7d9ba12e5877addf04fb2bf8fca38e86ac/monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7", size = 7615 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/67/7e8406a29b6c45be7af7740456f7f37025f0506ae2e05fb9009a53946860/monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c", size = 8154 },
]
[[package]]
name = "more-itertools"
version = "9.0.0"
@@ -3993,7 +3984,7 @@ requires-dist = [
{ name = "pdpyras", specifier = "==5.2.0" },
{ name = "phonenumberslite", specifier = "==8.13.6" },
{ name = "pillow", specifier = "==10.2.0" },
{ name = "posthoganalytics", specifier = ">=3.24.1" },
{ name = "posthoganalytics", specifier = ">=4.2.0" },
{ name = "psutil", specifier = "==6.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.4" },
{ name = "psycopg2-binary", specifier = "==2.9.7" },
@@ -4114,19 +4105,18 @@ dev = [
[[package]]
name = "posthoganalytics"
version = "3.24.1"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "monotonic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/b7/f6617e31ae91929e71712b63e0d47a1703f8c15b8181e1ae85df85a00ff8/posthoganalytics-3.24.1.tar.gz", hash = "sha256:38aecaee8d2b9f63fa1d19ab22d35eb3128ee525d3800e464cadf2f3495e5666", size = 72226 }
sdist = { url = "https://files.pythonhosted.org/packages/61/05/90dd1ab541498c72864b0395f1b0b3dd3970c3a6cba93b9a540903ceb1f0/posthoganalytics-4.2.0.tar.gz", hash = "sha256:8638da531a3b232bf935b84e138353c643a5cd68a70a078f56f004e7a765956a", size = 80332 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3a/a77494779f1e229922584bd3ed944e0b5ef00829720448d90da689f337c0/posthoganalytics-3.24.1-py2.py3-none-any.whl", hash = "sha256:e93b6f34e787f2e3cddce1a1913abc4be167b978c07385491f1dbfc3d9aeb392", size = 86501 },
{ url = "https://files.pythonhosted.org/packages/88/5f/91a2a96e803a56aabecdae7d572fb0a31dba61288b1520b950e6e9e3977c/posthoganalytics-4.2.0-py2.py3-none-any.whl", hash = "sha256:d77c3fc7ea43e637f8add48bfb8f297ad53b1c201716549a6306c0bd591f0852", size = 97514 },
]
[[package]]