|
22 | 22 | from sentry.search.events.types import SAMPLING_MODES, SnubaParams |
23 | 23 | from sentry.seer.autofix.autofix import get_all_tags_overview |
24 | 24 | from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS |
| 25 | +from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data |
25 | 26 | from sentry.seer.sentry_data_models import EAPTrace |
26 | 27 | from sentry.services.eventstore.models import Event, GroupEvent |
27 | 28 | from sentry.snuba.referrer import Referrer |
@@ -433,6 +434,166 @@ def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, An |
433 | 434 | return trace.dict() if trace else {} |
434 | 435 |
|
435 | 436 |
|
| 437 | +def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[str, Any]: |
| 438 | + """ |
| 439 | + Fetch and format a profile flamegraph by profile ID (8-char or full 32-char). |
| 440 | +
|
| 441 | + This function: |
| 442 | + 1. Queries EAP spans across all projects in the organization |
| 443 | + 2. Uses 14-day sliding windows to search up to 90 days back |
| 444 | + 3. Finds spans with matching profile_id/profiler_id and aggregates timestamps |
| 445 | + 4. Fetches the raw profile data from the profiling service |
| 446 | + 5. Converts to execution tree and formats as ASCII flamegraph |
| 447 | +
|
| 448 | + Args: |
| 449 | + profile_id: Profile ID - can be 8 characters (prefix) or full 32 characters |
| 450 | + organization_id: Organization ID to search within |
| 451 | +
|
| 452 | + Returns: |
| 453 | + Dictionary with either: |
| 454 | + - Success: {"formatted_profile": str, "metadata": dict} |
| 455 | + - Failure: {"error": str} |
| 456 | + """ |
| 457 | + try: |
| 458 | + organization = Organization.objects.get(id=organization_id) |
| 459 | + except Organization.DoesNotExist: |
| 460 | + logger.warning( |
| 461 | + "rpc_get_profile_flamegraph: Organization not found", |
| 462 | + extra={"organization_id": organization_id}, |
| 463 | + ) |
| 464 | + return {"error": "Organization not found"} |
| 465 | + |
| 466 | + # Get all projects for the organization |
| 467 | + projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)) |
| 468 | + |
| 469 | + if not projects: |
| 470 | + logger.warning( |
| 471 | + "rpc_get_profile_flamegraph: No projects found for organization", |
| 472 | + extra={"organization_id": organization_id}, |
| 473 | + ) |
| 474 | + return {"error": "No projects found for organization"} |
| 475 | + |
| 476 | + # Search up to 90 days back using 14-day sliding windows |
| 477 | + now = datetime.now(UTC) |
| 478 | + window_days = 14 |
| 479 | + max_days = 90 |
| 480 | + |
| 481 | + full_profile_id: str | None = None |
| 482 | + full_profiler_id: str | None = None |
| 483 | + project_id: int | None = None |
| 484 | + min_start_ts: float | None = None |
| 485 | + max_end_ts: float | None = None |
| 486 | + |
| 487 | + # Slide back in time in 14-day windows |
| 488 | + for days_back in range(0, max_days, window_days): |
| 489 | + window_end = now - timedelta(days=days_back) |
| 490 | + window_start = now - timedelta(days=min(days_back + window_days, max_days)) |
| 491 | + |
| 492 | + snuba_params = SnubaParams( |
| 493 | + start=window_start, |
| 494 | + end=window_end, |
| 495 | + projects=projects, |
| 496 | + organization=organization, |
| 497 | + ) |
| 498 | + |
| 499 | + # Query with aggregation to get profile metadata |
| 500 | + result = Spans.run_table_query( |
| 501 | + params=snuba_params, |
| 502 | + query_string=f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)", |
| 503 | + selected_columns=[ |
| 504 | + "profile.id", |
| 505 | + "profiler.id", |
| 506 | + "project.id", |
| 507 | + "min(precise.start_ts)", |
| 508 | + "max(precise.finish_ts)", |
| 509 | + ], |
| 510 | + orderby=[], |
| 511 | + offset=0, |
| 512 | + limit=1, |
| 513 | + referrer=Referrer.SEER_RPC, |
| 514 | + config=SearchResolverConfig( |
| 515 | + auto_fields=True, |
| 516 | + ), |
| 517 | + sampling_mode="NORMAL", |
| 518 | + ) |
| 519 | + |
| 520 | + data = result.get("data") |
| 521 | + if data: |
| 522 | + row = data[0] |
| 523 | + full_profile_id = row.get("profile.id") |
| 524 | + full_profiler_id = row.get("profiler.id") |
| 525 | + project_id = row.get("project.id") |
| 526 | + min_start_ts = row.get("min(precise.start_ts)") |
| 527 | + max_end_ts = row.get("max(precise.finish_ts)") |
| 528 | + break |
| 529 | + |
| 530 | + # Determine profile type and actual ID to use |
| 531 | + is_continuous = bool(full_profiler_id and not full_profile_id) |
| 532 | + actual_profile_id = full_profiler_id or full_profile_id |
| 533 | + |
| 534 | + if not actual_profile_id: |
| 535 | + logger.info( |
| 536 | + "rpc_get_profile_flamegraph: Profile not found", |
| 537 | + extra={"profile_id": profile_id, "organization_id": organization_id}, |
| 538 | + ) |
| 539 | + return {"error": "Profile not found in the last 90 days"} |
| 540 | + if not project_id: |
| 541 | + logger.warning( |
| 542 | + "rpc_get_profile_flamegraph: Could not find project id for profile", |
| 543 | + extra={"profile_id": profile_id, "organization_id": organization_id}, |
| 544 | + ) |
| 545 | + return {"error": "Project not found"} |
| 546 | + |
| 547 | + logger.info( |
| 548 | + "rpc_get_profile_flamegraph: Found profile", |
| 549 | + extra={ |
| 550 | + "profile_id": actual_profile_id, |
| 551 | + "project_id": project_id, |
| 552 | + "is_continuous": is_continuous, |
| 553 | + "min_start_ts": min_start_ts, |
| 554 | + "max_end_ts": max_end_ts, |
| 555 | + }, |
| 556 | + ) |
| 557 | + |
| 558 | + # Fetch the profile data |
| 559 | + profile_data = fetch_profile_data( |
| 560 | + profile_id=actual_profile_id, |
| 561 | + organization_id=organization_id, |
| 562 | + project_id=project_id, |
| 563 | + start_ts=min_start_ts, |
| 564 | + end_ts=max_end_ts, |
| 565 | + is_continuous=is_continuous, |
| 566 | + ) |
| 567 | + |
| 568 | + if not profile_data: |
| 569 | + logger.warning( |
| 570 | + "rpc_get_profile_flamegraph: Failed to fetch profile data from profiling service", |
| 571 | + extra={"profile_id": actual_profile_id, "project_id": project_id}, |
| 572 | + ) |
| 573 | + return {"error": "Failed to fetch profile data from profiling service"} |
| 574 | + |
| 575 | + # Convert to execution tree (returns dicts, not Pydantic models) |
| 576 | + execution_tree = _convert_profile_to_execution_tree(profile_data) |
| 577 | + |
| 578 | + if not execution_tree: |
| 579 | + logger.warning( |
| 580 | + "rpc_get_profile_flamegraph: Empty execution tree", |
| 581 | + extra={"profile_id": actual_profile_id, "project_id": project_id}, |
| 582 | + ) |
| 583 | + return {"error": "Failed to generate execution tree from profile data"} |
| 584 | + |
| 585 | + return { |
| 586 | + "execution_tree": execution_tree, |
| 587 | + "metadata": { |
| 588 | + "profile_id": actual_profile_id, |
| 589 | + "project_id": project_id, |
| 590 | + "is_continuous": is_continuous, |
| 591 | + "start_ts": min_start_ts, |
| 592 | + "end_ts": max_end_ts, |
| 593 | + }, |
| 594 | + } |
| 595 | + |
| 596 | + |
436 | 597 | def get_repository_definition(*, organization_id: int, repo_full_name: str) -> dict | None: |
437 | 598 | """ |
438 | 599 | Look up a repository by full name (owner/repo-name) that the org has access to. |
|
0 commit comments