{thread.map((message, index) => {
if (isHumanMessage(message)) {
return (
@@ -51,6 +60,31 @@ export function Thread(): JSX.Element | null {
)
}
+ if (isFailureMessage(message)) {
+ return (
+
}
+ size="small"
+ className="mt-2"
+ type="secondary"
+ onClick={() => retryLastMessage()}
+ >
+ Try again
+
+ )
+ }
+ >
+ {message.content ||
Max has failed to generate an answer. Please try again.}
+
+ )
+ }
+
return null
})}
{threadLoading && (
@@ -65,23 +99,27 @@ export function Thread(): JSX.Element | null {
)
}
-const Message = React.forwardRef
>(
- function Message({ type, children, className }, ref): JSX.Element {
- if (type === AssistantMessageType.Human) {
- return (
-
- {children}
-
- )
- }
-
+const Message = React.forwardRef<
+ HTMLDivElement,
+ React.PropsWithChildren<{ type: 'human' | 'ai'; className?: string; action?: React.ReactNode }>
+>(function Message({ type, children, className, action }, ref): JSX.Element {
+ if (type === AssistantMessageType.Human) {
return (
-
+
{children}
)
}
-)
+
+ return (
+
+
+ {children}
+
+ {action}
+
+ )
+})
function Answer({
message,
@@ -107,7 +145,17 @@ function Answer({
return (
<>
{message.reasoning_steps && (
-
+ } status="warning" size="small">
+ Max is generating this answer one more time because the previous attempt has failed.
+
+ )
+ }
+ className={status === 'error' ? 'border-warning' : undefined}
+ >
{message.reasoning_steps.map((step, index) => (
- {step}
diff --git a/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts b/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
index 18b82c1947dc6..3bd38eb1e62cc 100644
--- a/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
+++ b/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
@@ -1,3 +1,25 @@
+import { AssistantGenerationStatusEvent, AssistantGenerationStatusType } from '~/queries/schema'
+
import chatResponse from './chatResponse.json'
+import failureResponse from './failureResponse.json'
+
+function generateChunk(events: string[]): string {
+ return events.map((event) => (event.startsWith('event:') ? `${event}\n` : `${event}\n\n`)).join('')
+}
+
+export const chatResponseChunk = generateChunk(['event: message', `data: ${JSON.stringify(chatResponse)}`])
+
+const generationFailure: AssistantGenerationStatusEvent = { type: AssistantGenerationStatusType.GenerationError }
+const responseWithReasoningStepsOnly = {
+ ...chatResponse,
+ answer: null,
+}
+
+export const generationFailureChunk = generateChunk([
+ 'event: message',
+ `data: ${JSON.stringify(responseWithReasoningStepsOnly)}`,
+ 'event: status',
+ `data: ${JSON.stringify(generationFailure)}`,
+])
-export const chatResponseChunk = `data: ${JSON.stringify(chatResponse)}\n\n`
+export const failureChunk = generateChunk(['event: message', `data: ${JSON.stringify(failureResponse)}`])
diff --git a/frontend/src/scenes/max/__mocks__/failureResponse.json b/frontend/src/scenes/max/__mocks__/failureResponse.json
new file mode 100644
index 0000000000000..3d9476fc3d958
--- /dev/null
+++ b/frontend/src/scenes/max/__mocks__/failureResponse.json
@@ -0,0 +1,4 @@
+{
+ "type": "ai/failure",
+ "content": "Oops! It looks like I’m having trouble generating this trends insight. Could you please try again?"
+}
diff --git a/frontend/src/scenes/max/maxLogic.ts b/frontend/src/scenes/max/maxLogic.ts
index 4d722a6fecadd..aa8ddb63805f2 100644
--- a/frontend/src/scenes/max/maxLogic.ts
+++ b/frontend/src/scenes/max/maxLogic.ts
@@ -1,10 +1,21 @@
+import { captureException } from '@sentry/react'
import { shuffle } from 'd3'
import { createParser } from 'eventsource-parser'
import { actions, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
-
-import { AssistantMessageType, NodeKind, RootAssistantMessage, SuggestedQuestionsQuery } from '~/queries/schema'
+import { isHumanMessage, isVisualizationMessage } from 'scenes/max/utils'
+
+import {
+ AssistantEventType,
+ AssistantGenerationStatusEvent,
+ AssistantGenerationStatusType,
+ AssistantMessageType,
+ FailureMessage,
+ NodeKind,
+ RootAssistantMessage,
+ SuggestedQuestionsQuery,
+} from '~/queries/schema'
import type { maxLogicType } from './maxLogicType'
@@ -18,6 +29,11 @@ export type ThreadMessage = RootAssistantMessage & {
status?: MessageStatus
}
+const FAILURE_MESSAGE: FailureMessage = {
+ type: AssistantMessageType.Failure,
+ content: 'Oops! It looks like I’m having trouble generating this trends insight. Could you please try again?',
+}
+
export const maxLogic = kea([
path(['scenes', 'max', 'maxLogic']),
props({} as MaxLogicProps),
@@ -31,6 +47,7 @@ export const maxLogic = kea([
setQuestion: (question: string) => ({ question }),
setVisibleSuggestions: (suggestions: string[]) => ({ suggestions }),
shuffleVisibleSuggestions: true,
+ retryLastMessage: true,
}),
reducers({
question: [
@@ -132,24 +149,34 @@ export const maxLogic = kea([
let firstChunk = true
const parser = createParser({
- onEvent: (event) => {
- const parsedResponse = parseResponse(event.data)
-
- if (!parsedResponse) {
- return
- }
+ onEvent: ({ data, event }) => {
+ if (event === AssistantEventType.Message) {
+ const parsedResponse = parseResponse(data)
+ if (!parsedResponse) {
+ return
+ }
- if (firstChunk) {
- firstChunk = false
+ if (firstChunk) {
+ firstChunk = false
+
+ if (parsedResponse) {
+ actions.addMessage({ ...parsedResponse, status: 'loading' })
+ }
+ } else if (parsedResponse) {
+ actions.replaceMessage(newIndex, {
+ ...parsedResponse,
+ status: values.thread[newIndex].status,
+ })
+ }
+ } else if (event === AssistantEventType.Status) {
+ const parsedResponse = parseResponse(data)
+ if (!parsedResponse) {
+ return
+ }
- if (parsedResponse) {
- actions.addMessage({ ...parsedResponse, status: 'loading' })
+ if (parsedResponse.type === AssistantGenerationStatusType.GenerationError) {
+ actions.setMessageStatus(newIndex, 'error')
}
- } else if (parsedResponse) {
- actions.replaceMessage(newIndex, {
- ...parsedResponse,
- status: 'loading',
- })
}
},
})
@@ -160,16 +187,41 @@ export const maxLogic = kea([
parser.feed(decoder.decode(value))
if (done) {
- actions.setMessageStatus(newIndex, 'completed')
+ const generatedMessage = values.thread[newIndex]
+ if (generatedMessage && isVisualizationMessage(generatedMessage) && generatedMessage.plan) {
+ actions.setMessageStatus(newIndex, 'completed')
+ } else if (generatedMessage) {
+ actions.replaceMessage(newIndex, FAILURE_MESSAGE)
+ } else {
+ actions.addMessage({
+ ...FAILURE_MESSAGE,
+ status: 'completed',
+ })
+ }
break
}
}
- } catch {
- actions.setMessageStatus(values.thread.length - 1 === newIndex ? newIndex : newIndex - 1, 'error')
+ } catch (e) {
+ captureException(e)
+
+ if (values.thread[newIndex]) {
+ actions.replaceMessage(newIndex, FAILURE_MESSAGE)
+ } else {
+ actions.addMessage({
+ ...FAILURE_MESSAGE,
+ status: 'completed',
+ })
+ }
}
actions.setThreadLoaded()
},
+ retryLastMessage: () => {
+ const lastMessage = values.thread.filter(isHumanMessage).pop()
+ if (lastMessage) {
+ actions.askMax(lastMessage.content)
+ }
+ },
})),
selectors({
sessionId: [(_, p) => [p.sessionId], (sessionId) => sessionId],
@@ -180,10 +232,10 @@ export const maxLogic = kea([
* Parses the generation result from the API. Some generation chunks might be sent in batches.
* @param response
*/
-function parseResponse(response: string): RootAssistantMessage | null | undefined {
+function parseResponse(response: string): T | null | undefined {
try {
const parsed = JSON.parse(response)
- return parsed as RootAssistantMessage | null | undefined
+ return parsed as T | null | undefined
} catch {
return null
}
diff --git a/frontend/src/scenes/max/utils.ts b/frontend/src/scenes/max/utils.ts
index 263eb2f521baf..84f2d1d4a2aba 100644
--- a/frontend/src/scenes/max/utils.ts
+++ b/frontend/src/scenes/max/utils.ts
@@ -1,4 +1,10 @@
-import { AssistantMessageType, HumanMessage, RootAssistantMessage, VisualizationMessage } from '~/queries/schema'
+import {
+ AssistantMessageType,
+ FailureMessage,
+ HumanMessage,
+ RootAssistantMessage,
+ VisualizationMessage,
+} from '~/queries/schema'
export function isVisualizationMessage(
message: RootAssistantMessage | undefined | null
@@ -9,3 +15,7 @@ export function isVisualizationMessage(
export function isHumanMessage(message: RootAssistantMessage | undefined | null): message is HumanMessage {
return message?.type === AssistantMessageType.Human
}
+
+export function isFailureMessage(message: RootAssistantMessage | undefined | null): message is FailureMessage {
+ return message?.type === AssistantMessageType.Failure
+}
diff --git a/posthog/api/query.py b/posthog/api/query.py
index f2eaccea53ae5..beb1415b8dc38 100644
--- a/posthog/api/query.py
+++ b/posthog/api/query.py
@@ -41,7 +41,14 @@
ClickHouseSustainedRateThrottle,
HogQLQueryThrottle,
)
-from posthog.schema import HumanMessage, QueryRequest, QueryResponseAlternative, QueryStatusResponse
+from posthog.schema import (
+ AssistantEventType,
+ AssistantGenerationStatusEvent,
+ HumanMessage,
+ QueryRequest,
+ QueryResponseAlternative,
+ QueryStatusResponse,
+)
class ServerSentEventRenderer(BaseRenderer):
@@ -185,7 +192,11 @@ def generate():
last_message = None
for message in assistant.stream(validated_body):
last_message = message
- yield f"data: {message}\n\n"
+ if isinstance(message, AssistantGenerationStatusEvent):
+ yield f"event: {AssistantEventType.STATUS}\n"
+ else:
+ yield f"event: {AssistantEventType.MESSAGE}\n"
+ yield f"data: {message.model_dump_json()}\n\n"
human_message = validated_body.messages[-1].root
if isinstance(human_message, HumanMessage):
diff --git a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py
index 23ba38cd742a3..acb648172d287 100644
--- a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py
+++ b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py
@@ -16,6 +16,7 @@
ExperimentFunnelsQueryResponse,
ExperimentSignificanceCode,
ExperimentVariantFunnelsBaseStats,
+ FunnelsFilter,
FunnelsQuery,
FunnelsQueryResponse,
InsightDateRange,
@@ -46,6 +47,11 @@ def calculate(self) -> ExperimentFunnelsQueryResponse:
self._validate_event_variants(funnels_result)
+ # Filter results to only include valid variants in the first step
+ funnels_result.results = [
+ result for result in funnels_result.results if result[0]["breakdown_value"][0] in self.variants
+ ]
+
# Statistical analysis
control_variant, test_variants = self._get_variants_with_base_stats(funnels_result)
probabilities = calculate_probabilities(control_variant, test_variants)
@@ -76,8 +82,8 @@ def _prepare_funnel_query(self) -> FunnelsQuery:
2. Configure the breakdown to use the feature flag key, which allows us
to separate results for different experiment variants.
"""
- # Clone the source query
- prepared_funnels_query = FunnelsQuery(**self.query.source.model_dump())
+ # Clone the funnels query
+ prepared_funnels_query = FunnelsQuery(**self.query.funnels_query.model_dump())
# Set the date range to match the experiment's duration, using the project's timezone
if self.team.timezone:
@@ -100,6 +106,10 @@ def _prepare_funnel_query(self) -> FunnelsQuery:
breakdown_type="event",
)
+ prepared_funnels_query.funnelsFilter = FunnelsFilter(
+ funnelVizType="steps",
+ )
+
return prepared_funnels_query
def _get_variants_with_base_stats(
@@ -180,4 +190,4 @@ def _validate_event_variants(self, funnels_result: FunnelsQueryResponse):
raise ValidationError(detail=json.dumps(errors))
def to_query(self) -> ast.SelectQuery:
- raise ValueError(f"Cannot convert source query of type {self.query.source.kind} to query")
+ raise ValueError(f"Cannot convert source query of type {self.query.funnels_query.kind} to query")
diff --git a/posthog/hogql_queries/experiments/test/test_experiment_funnels_query_runner.py b/posthog/hogql_queries/experiments/test/test_experiment_funnels_query_runner.py
index 005fe82e089ae..9d4963bb59824 100644
--- a/posthog/hogql_queries/experiments/test/test_experiment_funnels_query_runner.py
+++ b/posthog/hogql_queries/experiments/test/test_experiment_funnels_query_runner.py
@@ -69,7 +69,7 @@ def test_query_runner(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
experiment.metrics = [{"type": "primary", "query": experiment_query.model_dump()}]
@@ -130,7 +130,7 @@ def test_query_runner_standard_flow(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
experiment.metrics = [{"type": "primary", "query": experiment_query.model_dump()}]
@@ -213,7 +213,7 @@ def test_validate_event_variants_no_events(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
query_runner = ExperimentFunnelsQueryRunner(query=experiment_query, team=self.team)
@@ -255,7 +255,7 @@ def test_validate_event_variants_no_control(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
query_runner = ExperimentFunnelsQueryRunner(query=experiment_query, team=self.team)
@@ -297,7 +297,7 @@ def test_validate_event_variants_no_test(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
query_runner = ExperimentFunnelsQueryRunner(query=experiment_query, team=self.team)
@@ -341,7 +341,7 @@ def test_validate_event_variants_no_flag_info(self):
experiment_query = ExperimentFunnelsQuery(
experiment_id=experiment.id,
kind="ExperimentFunnelsQuery",
- source=funnels_query,
+ funnels_query=funnels_query,
)
query_runner = ExperimentFunnelsQueryRunner(query=experiment_query, team=self.team)
diff --git a/posthog/schema.py b/posthog/schema.py
index 42c5b0d08868b..de42df84b396e 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -63,6 +63,16 @@ class AlertState(StrEnum):
SNOOZED = "Snoozed"
+class AssistantEventType(StrEnum):
+ STATUS = "status"
+ MESSAGE = "message"
+
+
+class AssistantGenerationStatusType(StrEnum):
+ ACK = "ack"
+ GENERATION_ERROR = "generation_error"
+
+
class AssistantMessage(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -75,6 +85,7 @@ class AssistantMessageType(StrEnum):
HUMAN = "human"
AI = "ai"
AI_VIZ = "ai/viz"
+ AI_FAILURE = "ai/failure"
class AutocompleteCompletionItemKind(StrEnum):
@@ -530,6 +541,14 @@ class ExperimentVariantTrendsBaseStats(BaseModel):
key: str
+class FailureMessage(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ content: Optional[str] = None
+ type: Literal["ai/failure"] = "ai/failure"
+
+
class FilterLogicalOperator(StrEnum):
AND_ = "AND"
OR_ = "OR"
@@ -1668,6 +1687,13 @@ class AlertCondition(BaseModel):
type: AlertConditionType
+class AssistantGenerationStatusEvent(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ type: AssistantGenerationStatusType
+
+
class AutocompleteCompletionItem(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -5960,8 +5986,8 @@ class QueryResponseAlternative(
]
-class RootAssistantMessage(RootModel[Union[VisualizationMessage, AssistantMessage, HumanMessage]]):
- root: Union[VisualizationMessage, AssistantMessage, HumanMessage]
+class RootAssistantMessage(RootModel[Union[VisualizationMessage, AssistantMessage, HumanMessage, FailureMessage]]):
+ root: Union[VisualizationMessage, AssistantMessage, HumanMessage, FailureMessage]
class CachedExperimentFunnelsQueryResponse(BaseModel):
@@ -6157,12 +6183,12 @@ class ExperimentFunnelsQuery(BaseModel):
extra="forbid",
)
experiment_id: int
+ funnels_query: FunnelsQuery
kind: Literal["ExperimentFunnelsQuery"] = "ExperimentFunnelsQuery"
modifiers: Optional[HogQLQueryModifiers] = Field(
default=None, description="Modifiers used when performing the query"
)
response: Optional[ExperimentFunnelsQueryResponse] = None
- source: FunnelsQuery
class FunnelCorrelationQuery(BaseModel):
diff --git a/posthog/temporal/batch_exports/temporary_file.py b/posthog/temporal/batch_exports/temporary_file.py
index 19973d3d84617..d26db8b976171 100644
--- a/posthog/temporal/batch_exports/temporary_file.py
+++ b/posthog/temporal/batch_exports/temporary_file.py
@@ -482,7 +482,7 @@ def write_dict(self, d: dict[str, typing.Any]) -> int:
# We tried, fallback to the slower but more permissive stdlib
# json.
logger.exception("PostHog $web_vitals event didn't match expected structure")
- dumped = json.dumps(d).encode("utf-8")
+ dumped = json.dumps(d, default=str).encode("utf-8")
n = self.batch_export_file.write(dumped + b"\n")
else:
dumped = orjson.dumps(d, default=str)
@@ -492,7 +492,7 @@ def write_dict(self, d: dict[str, typing.Any]) -> int:
# In this case, we fallback to the slower but more permissive stdlib
# json.
logger.exception("Orjson detected a deeply nested dict: %s", d)
- dumped = json.dumps(d).encode("utf-8")
+ dumped = json.dumps(d, default=str).encode("utf-8")
n = self.batch_export_file.write(dumped + b"\n")
else:
# Orjson is very strict about invalid unicode. This slow path protects us
diff --git a/rust/common/metrics/src/lib.rs b/rust/common/metrics/src/lib.rs
index 9e82e98cc004f..967188db29fc3 100644
--- a/rust/common/metrics/src/lib.rs
+++ b/rust/common/metrics/src/lib.rs
@@ -46,7 +46,8 @@ pub fn setup_metrics_routes(router: Router) -> Router {
pub fn setup_metrics_recorder() -> PrometheusHandle {
const BUCKETS: &[f64] = &[
- 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 50.0, 100.0, 250.0,
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 50.0, 100.0, 250.0, 500.0,
+ 1000.0, 2000.0, 5000.0, 10000.0,
];
PrometheusBuilder::new()
diff --git a/rust/cymbal/src/langs/js.rs b/rust/cymbal/src/langs/js.rs
index 89deafdc46479..e8921d614518d 100644
--- a/rust/cymbal/src/langs/js.rs
+++ b/rust/cymbal/src/langs/js.rs
@@ -62,7 +62,7 @@ impl RawJSFrame {
(self, e).into()
}
- fn source_url(&self) -> Result {
+ pub fn source_url(&self) -> Result {
// We can't resolve a frame without a source ref, and are forced
// to assume this frame is not minified
let Some(source_url) = &self.source_url else {
diff --git a/rust/cymbal/src/main.rs b/rust/cymbal/src/main.rs
index 9ea6c02b4a065..8087bff817872 100644
--- a/rust/cymbal/src/main.rs
+++ b/rust/cymbal/src/main.rs
@@ -1,4 +1,4 @@
-use std::{future::ready, sync::Arc};
+use std::{collections::HashMap, future::ready, sync::Arc};
use axum::{routing::get, Router};
use common_kafka::kafka_consumer::RecvErr;
@@ -8,7 +8,10 @@ use cymbal::{
app_context::AppContext,
config::Config,
error::Error,
- metric_consts::{ERRORS, EVENT_RECEIVED, MAIN_LOOP_TIME, PER_STACK_TIME, STACK_PROCESSED},
+ metric_consts::{
+ ERRORS, EVENT_RECEIVED, MAIN_LOOP_TIME, PER_FRAME_GROUP_TIME, PER_STACK_TIME,
+ STACK_PROCESSED,
+ },
types::{frames::RawFrame, ErrProps},
};
use envconfig::Envconfig;
@@ -119,21 +122,37 @@ async fn main() -> Result<(), Error> {
let stack_trace: &Vec = &trace.frames;
let per_stack = common_metrics::timing_guard(PER_STACK_TIME, &[]);
- let mut frames = Vec::with_capacity(stack_trace.len());
+
+ // Cluster the frames by symbol set
+ let mut groups = HashMap::new();
for frame in stack_trace {
- match frame.resolve(event.team_id, &context.catalog).await {
- Ok(r) => frames.push(r),
- Err(err) => {
- metrics::counter!(ERRORS, "cause" => "frame_not_parsable").increment(1);
- error!("Error parsing stack frame: {:?}", err);
- continue;
+ let group = groups
+ .entry(frame.symbol_set_group_key())
+ .or_insert_with(Vec::new);
+ group.push(frame.clone());
+ }
+
+ let team_id = event.team_id;
+ let mut results = Vec::with_capacity(stack_trace.len());
+ for (_, frames) in groups.into_iter() {
+ context.worker_liveness.report_healthy().await; // TODO - we shouldn't need to do this, but we do for now.
+ let mut any_success = false;
+ let per_frame_group = common_metrics::timing_guard(PER_FRAME_GROUP_TIME, &[]);
+ for frame in frames {
+ results.push(frame.resolve(team_id, &context.catalog).await);
+ if results.last().unwrap().is_ok() {
+ any_success = true;
}
- };
+ }
+ per_frame_group
+ .label("resolved_any", if any_success { "true" } else { "false" })
+ .fin();
}
+
per_stack
.label(
"resolved_any",
- if frames.is_empty() { "true" } else { "false" },
+ if results.is_empty() { "true" } else { "false" },
)
.fin();
whole_loop.label("had_frame", "true").fin();
diff --git a/rust/cymbal/src/metric_consts.rs b/rust/cymbal/src/metric_consts.rs
index 093636eda9d26..aa46aa4719f6c 100644
--- a/rust/cymbal/src/metric_consts.rs
+++ b/rust/cymbal/src/metric_consts.rs
@@ -15,3 +15,4 @@ pub const STORE_CACHE_EVICTIONS: &str = "cymbal_store_cache_evictions";
pub const MAIN_LOOP_TIME: &str = "cymbal_main_loop_time";
pub const PER_FRAME_TIME: &str = "cymbal_per_frame_time";
pub const PER_STACK_TIME: &str = "cymbal_per_stack_time";
+pub const PER_FRAME_GROUP_TIME: &str = "cymbal_per_frame_group_time";
diff --git a/rust/cymbal/src/types/frames.rs b/rust/cymbal/src/types/frames.rs
index 816ef78ba3782..f14840cfb0073 100644
--- a/rust/cymbal/src/types/frames.rs
+++ b/rust/cymbal/src/types/frames.rs
@@ -27,6 +27,11 @@ impl RawFrame {
res
}
+
+ pub fn symbol_set_group_key(&self) -> String {
+ let RawFrame::JavaScript(raw) = self;
+ raw.source_url().map(String::from).unwrap_or_default()
+ }
}
// We emit a single, unified representation of a frame, which is what we pass on to users.