Skip to content

Commit 898654a

Browse files
authored
feat(ph-ai): PostHog properties dict in GenerationMetadata (#366)
1 parent 499d545 commit 898654a

File tree

4 files changed

+191
-1
lines changed

4 files changed

+191
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 6.9.3 - 2025-11-10
2+
3+
- feat(ph-ai): PostHog properties dict in GenerationMetadata
4+
15
# 6.9.2 - 2025-11-10
26

37
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs

posthog/ai/langchain/callbacks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
7979
"""Base URL of the provider's API used in the run."""
8080
tools: Optional[List[Dict[str, Any]]] = None
8181
"""Tools provided to the model."""
82+
posthog_properties: Optional[Dict[str, Any]] = None
83+
"""PostHog properties of the run."""
8284

8385

8486
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -420,6 +422,8 @@ def _set_llm_metadata(
420422
generation.model = model
421423
if provider := metadata.get("ls_provider"):
422424
generation.provider = provider
425+
426+
generation.posthog_properties = metadata.get("posthog_properties")
423427
try:
424428
base_url = serialized["kwargs"]["openai_api_base"]
425429
if base_url is not None:
@@ -566,6 +570,9 @@ def _capture_generation(
566570
"$ai_framework": "langchain",
567571
}
568572

573+
if isinstance(run.posthog_properties, dict):
574+
event_properties.update(run.posthog_properties)
575+
569576
if run.tools:
570577
event_properties["$ai_tools"] = run.tools
571578

posthog/test/ai/langchain/test_callbacks.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def test_metadata_capture(mock_client):
113113
base_url="https://us.posthog.com",
114114
name="test",
115115
end_time=None,
116+
posthog_properties=None,
116117
)
117118
assert callbacks._runs[run_id] == expected
118119
with patch("time.time", return_value=1234567891):
@@ -1269,6 +1270,7 @@ def test_metadata_tools(mock_client):
12691270
name="test",
12701271
tools=tools,
12711272
end_time=None,
1273+
posthog_properties=None,
12721274
)
12731275
assert callbacks._runs[run_id] == expected
12741276
with patch("time.time", return_value=1234567891):
@@ -2171,3 +2173,180 @@ def test_agent_action_and_finish_imports():
21712173
assert mock_client.capture.call_count == 1
21722174
call_args = mock_client.capture.call_args[1]
21732175
assert call_args["event"] == "$ai_span"
2176+
2177+
2178+
def test_posthog_properties_field_in_generation_metadata(mock_client):
2179+
"""Test that posthog_properties is properly stored in GenerationMetadata."""
2180+
callbacks = CallbackHandler(mock_client)
2181+
run_id = uuid.uuid4()
2182+
2183+
# Test with billable=True
2184+
with patch("time.time", return_value=1234567890):
2185+
callbacks._set_llm_metadata(
2186+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2187+
run_id,
2188+
messages=[{"role": "user", "content": "Test message"}],
2189+
invocation_params={"temperature": 0.5},
2190+
metadata={
2191+
"ls_model_name": "gpt-4o",
2192+
"ls_provider": "openai",
2193+
"posthog_properties": {"$ai_billable": True},
2194+
},
2195+
name="test",
2196+
)
2197+
2198+
expected = GenerationMetadata(
2199+
model="gpt-4o",
2200+
input=[{"role": "user", "content": "Test message"}],
2201+
start_time=1234567890,
2202+
model_params={"temperature": 0.5},
2203+
provider="openai",
2204+
base_url="https://api.openai.com",
2205+
name="test",
2206+
posthog_properties={"$ai_billable": True},
2207+
end_time=None,
2208+
)
2209+
assert callbacks._runs[run_id] == expected
2210+
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
2211+
2212+
callbacks._pop_run_metadata(run_id)
2213+
2214+
# Test with billable=False (explicit)
2215+
run_id2 = uuid.uuid4()
2216+
with patch("time.time", return_value=1234567890):
2217+
callbacks._set_llm_metadata(
2218+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2219+
run_id2,
2220+
messages=[{"role": "user", "content": "Test message"}],
2221+
invocation_params={"temperature": 0.5},
2222+
metadata={
2223+
"ls_model_name": "gpt-4o",
2224+
"ls_provider": "openai",
2225+
"posthog_properties": {"$ai_billable": False},
2226+
},
2227+
name="test",
2228+
)
2229+
2230+
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
2231+
callbacks._pop_run_metadata(run_id2)
2232+
2233+
# Test when posthog_properties not provided
2234+
run_id3 = uuid.uuid4()
2235+
with patch("time.time", return_value=1234567890):
2236+
callbacks._set_llm_metadata(
2237+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2238+
run_id3,
2239+
messages=[{"role": "user", "content": "Test message"}],
2240+
invocation_params={"temperature": 0.5},
2241+
metadata={"ls_model_name": "gpt-4o", "ls_provider": "openai"},
2242+
name="test",
2243+
)
2244+
2245+
assert callbacks._runs[run_id3].posthog_properties is None
2246+
2247+
2248+
def test_billable_property_in_generation_event(mock_client):
2249+
"""Test that the billable property is captured in the $ai_generation event."""
2250+
callbacks = CallbackHandler(mock_client)
2251+
2252+
# We need to test the _set_llm_metadata directly since FakeMessagesListChatModel
2253+
# doesn't support metadata in the same way as real models
2254+
run_id = uuid.uuid4()
2255+
with patch("time.time", return_value=1234567890):
2256+
callbacks._set_llm_metadata(
2257+
{},
2258+
run_id,
2259+
messages=[{"role": "user", "content": "Test"}],
2260+
metadata={
2261+
"posthog_properties": {"$ai_billable": True},
2262+
"ls_model_name": "test-model",
2263+
},
2264+
invocation_params={},
2265+
)
2266+
2267+
mock_response = MagicMock()
2268+
mock_response.generations = [[MagicMock()]]
2269+
2270+
with patch("time.time", return_value=1234567891):
2271+
run = callbacks._pop_run_metadata(run_id)
2272+
2273+
callbacks._capture_generation(
2274+
trace_id=run_id,
2275+
run_id=run_id,
2276+
run=run,
2277+
output=mock_response,
2278+
parent_run_id=None,
2279+
)
2280+
2281+
assert mock_client.capture.call_count == 1
2282+
call_args = mock_client.capture.call_args[1]
2283+
props = call_args["properties"]
2284+
2285+
assert call_args["event"] == "$ai_generation"
2286+
assert props["$ai_billable"] is True
2287+
2288+
2289+
def test_billable_defaults_to_false_in_event(mock_client):
2290+
"""Test that $ai_billable is not present when not specified."""
2291+
prompt = ChatPromptTemplate.from_messages([("user", "Test query")])
2292+
model = FakeMessagesListChatModel(
2293+
responses=[AIMessage(content="Test response")],
2294+
)
2295+
2296+
callbacks = [CallbackHandler(mock_client)]
2297+
chain = prompt | model
2298+
chain.invoke({}, config={"callbacks": callbacks})
2299+
2300+
generation_call = None
2301+
for call in mock_client.capture.call_args_list:
2302+
if call[1]["event"] == "$ai_generation":
2303+
generation_call = call
2304+
break
2305+
2306+
assert generation_call is not None
2307+
props = generation_call[1]["properties"]
2308+
assert "$ai_billable" not in props
2309+
2310+
2311+
def test_billable_with_real_chain(mock_client):
2312+
"""Test billable tracking through a complete chain execution with mocked metadata."""
2313+
callbacks = CallbackHandler(mock_client)
2314+
run_id = uuid.uuid4()
2315+
2316+
with patch("time.time", return_value=1000.0):
2317+
callbacks._set_llm_metadata(
2318+
{},
2319+
run_id,
2320+
messages=[{"role": "user", "content": "What's the weather?"}],
2321+
metadata={
2322+
"ls_model_name": "fake-model",
2323+
"ls_provider": "fake",
2324+
"posthog_properties": {"$ai_billable": True},
2325+
},
2326+
invocation_params={"temperature": 0.7},
2327+
)
2328+
2329+
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
2330+
2331+
mock_response = MagicMock()
2332+
mock_response.generations = [[MagicMock()]]
2333+
2334+
with patch("time.time", return_value=1001.0):
2335+
run = callbacks._pop_run_metadata(run_id)
2336+
2337+
callbacks._capture_generation(
2338+
trace_id=run_id,
2339+
run_id=run_id,
2340+
run=run,
2341+
output=mock_response,
2342+
parent_run_id=None,
2343+
)
2344+
2345+
assert mock_client.capture.call_count == 1
2346+
call_args = mock_client.capture.call_args[1]
2347+
props = call_args["properties"]
2348+
2349+
assert call_args["event"] == "$ai_generation"
2350+
assert props["$ai_billable"] is True
2351+
assert props["$ai_model"] == "fake-model"
2352+
assert props["$ai_provider"] == "fake"

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "6.9.2"
1+
VERSION = "6.9.3"
22

33
if __name__ == "__main__":
44
print(VERSION, end="") # noqa: T201

0 commit comments

Comments
 (0)