Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,9 @@ export const FEATURE_FLAGS = {
TASK_TOOL: 'phai-task-tool', // owner: @kappa90 #team-posthog-ai
TASKS: 'tasks', // owner: #team-ai-observability
TASKS_STREAM_VIA_PROXY: 'tasks-stream-via-proxy', // owner: #team-ai-observability
TAXONOMIC_FILTER_EVENT_MATCH: 'taxonomic-filter-event-match', // owner: @pauldambra, asks the decision model which core events an events search with no results describes, then suggests them in the empty state
TAXONOMIC_FILTER_MENU_REBUILD: 'taxonomic-filter-menu-rebuild', // owner: @adamleith, opt-in to the rebuilt TaxonomicFilter — headless filter panel + new popover menu (column / preview-pane)
TAXONOMIC_FILTER_SEARCH_INTENT: 'taxonomic-filter-search-intent', // owner: @pauldambra multivariate=control,banner,promote, classifies a picker search with the decision model. Every arm promotes the predicted group on the All tab; only banner also suggests the right tab
TAXONOMIC_FILTER_EVENT_MATCH: 'taxonomic-filter-event-match', // owner: @pauldambra, asks the decision model which core events an events search with no results describes, then suggests them in the empty state
TEXT_CARD_WORD_ART: 'text-card-word-art', // owner: @jonmcwest, gates the word art insert button in dashboard text cards
TOOLBAR_PAID_HEATMAPS: 'toolbar-paid-heatmaps', // owner: #team-web-analytics
TRACING: 'tracing', // owner: #team-apm (@jonmcwest, @frankh)
Expand Down Expand Up @@ -598,6 +598,7 @@ export const FEATURE_FLAGS = {
WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows
WORKFLOWS_ISP_SENDING_HEALTH: 'workflows-isp-sending-health', // owner: #team-workflows
WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows
WORKFLOWS_LIST_V2: 'workflows-list-v2', // owner: #team-workflows
WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows
WORKFLOWS_TRIGGER_VOLUME_ESTIMATE: 'workflows-trigger-volume-estimate', // owner: @mayteio #team-workflows
XAA_AUTHENTICATION: 'xaa-authentication', // owner: @reecejones #team-platform-features
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface LemonButtonPropsBase
| 'aria-pressed'
| 'aria-expanded'
| 'aria-controls'
| 'aria-selected'
> {
children?: React.ReactNode
type?: 'primary' | 'secondary' | 'tertiary'
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ interface LemonInputPropsBase extends Pick<
| 'spellCheck'
| 'inputMode'
| 'pattern'
| 'role'
| 'aria-autocomplete'
| 'aria-expanded'
| 'aria-controls'
| 'aria-activedescendant'
> {
inputRef?: React.Ref<HTMLInputElement>
inputComponent?: React.JSXElementConstructor<
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/lib/lemon-ui/LemonSnack/LemonSnack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface LemonSnackProps extends React.HTMLAttributes<HTMLSpanElement> {
children?: React.ReactNode
onClick?: React.MouseEventHandler
onClose?: React.MouseEventHandler
/** Accessible name of the close button, for example "Remove filter Status: Active". */
closeLabel?: string
title?: string
wrap?: boolean
className?: string
Expand All @@ -19,7 +21,7 @@ export interface LemonSnackProps extends React.HTMLAttributes<HTMLSpanElement> {

export const LemonSnack: React.FunctionComponent<LemonSnackProps & React.RefAttributes<HTMLSpanElement>> = forwardRef(
function LemonSnack(
{ type = 'regular', children, wrap, onClick, onClose, title, className, ...rest },
{ type = 'regular', children, wrap, onClick, onClose, closeLabel, title, className, ...rest },
ref
): JSX.Element {
const isRegular = type === 'regular'
Expand Down Expand Up @@ -54,6 +56,7 @@ export const LemonSnack: React.FunctionComponent<LemonSnackProps & React.RefAttr
size="small"
noPadding
icon={<IconX />}
aria-label={closeLabel}
onClick={(e) => {
e.stopPropagation()
onClose(e)
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions posthog/settings/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,8 @@ def static_varies_origin(headers, path, url):
"^/api/organizations/@current/plugins/?$",
"^api/(environments|projects)/@current/feature_flags/my_flags/?$",
"^/?api/(environments|projects)/\\d+/query/?$",
# Summary rows have no step config, and the trigger's secret inputs are masked.
"^/?api/(environments|projects)/\\d+/hog_flows/summaries/?$",
# Deploy-static source catalog (no user input or secrets reflected): several
# hundred KB of JSON that compresses ~7x.
"^/?api/(environments|projects)/(\\d+|@current)/external_data_sources/wizard/?$",
Expand Down
12 changes: 12 additions & 0 deletions products/workflows/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,18 @@ Existing example:
- `posthog_assignee` type defined in nodejs/src/cdp/templates/\_destinations/posthog_conversations/posthog-update-ticket.template.ts
- Renderer in products/conversations/frontend/components/Assignee/CyclotronJobInputAssignee.tsx

## Listing workflows

`GET /api/projects/:id/hog_flows/` returns full workflows with the step graph.
MCP requests (`x-posthog-client: mcp`) get `HogFlowSummarySerializer` instead, which has no step graph.

`GET /api/projects/:id/hog_flows/summaries/` returns those summary fields plus `type`, for loading a whole project's list page by page.
It takes the same filters and search as the list. Keep these in mind when you change it:

- **It sorts on `-created_at, -id`.** A save during the load changes `updated_at`, so sorting on it moves rows between pages.
- **It applies the access-level filter itself.** `_filter_queryset_by_access_level` in the routing mixin only runs for `list`.
- **Only this path is gzipped.** The full list has step config next to the `search` input that its `next` link reflects, which is the shape `ScopedGZipMiddleware` warns about.

## Metrics and version attribution

Workflow metrics live in the ClickHouse `app_metrics2` table, written by the CDP workers.
Expand Down
181 changes: 131 additions & 50 deletions products/workflows/backend/api/hog_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
from posthog.models import Team
from posthog.models.filters import Filter
from posthog.models.integration import Integration
from posthog.permissions import posthog_feature_flag_enabled
from posthog.permissions import is_service_auth, posthog_feature_flag_enabled
from posthog.plugins.plugin_server_api import (
cancel_hog_flow_batch_job,
cancel_hog_flow_invocations,
Expand Down Expand Up @@ -3792,28 +3792,73 @@ class CommaSeparatedListFilter(BaseInFilter, CharFilter):
# by surface in the UI (see WorkflowTypeTag), so returning it under `messaging` would contradict the
# tag on the row. Accepting several lets a list say which surfaces it covers, which is how the
# workflows page asks for everything except the ones that moved out.
WORKFLOW_TYPES: Final[tuple[str, ...]] = ("messaging", "automation", "loop", "broadcast")
class HogFlowType(models.TextChoices):
MESSAGING = "messaging"
AUTOMATION = "automation"
LOOP = "loop"
BROADCAST = "broadcast"


WORKFLOW_TYPES: Final[tuple[str, ...]] = tuple(HogFlowType.values)
OWNED_WORKFLOW_TYPES: Final[dict[str, str]] = {
"loop": HogFlow.OriginProduct.LOOPS,
"broadcast": HogFlow.OriginProduct.BROADCASTS,
HogFlowType.LOOP: HogFlow.OriginProduct.LOOPS,
HogFlowType.BROADCAST: HogFlow.OriginProduct.BROADCASTS,
}


def _has_messaging_action_q() -> Q:
messaging = Q()
for action_type in MESSAGING_ACTION_TYPES:
messaging |= Q(actions__contains=[{"type": action_type}])
return messaging


def workflow_type_q(requested: set[str]) -> Q:
owned = Q(origin_product__in=[OWNED_WORKFLOW_TYPES[t] for t in requested if t in OWNED_WORKFLOW_TYPES])
behavioural = requested - set(OWNED_WORKFLOW_TYPES)
if not behavioural:
return owned

messaging = Q()
for action_type in MESSAGING_ACTION_TYPES:
messaging |= Q(actions__contains=[{"type": action_type}])
messaging = _has_messaging_action_q()
unowned = ~Q(origin_product__in=list(OWNED_WORKFLOW_TYPES.values()))
if behavioural == {"messaging", "automation"}:
return owned | unowned
return owned | (unowned & (messaging if behavioural == {"messaging"} else ~messaging))


def annotate_workflow_type(queryset: QuerySet) -> QuerySet:
"""Adds `workflow_type`, decided by the same rules as the `type` filter in workflow_type_q."""
return queryset.annotate(
workflow_type=models.Case(
*(
models.When(origin_product=origin_product, then=models.Value(workflow_type))
for workflow_type, origin_product in OWNED_WORKFLOW_TYPES.items()
),
models.When(_has_messaging_action_q(), then=models.Value(HogFlowType.MESSAGING)),
default=models.Value(HogFlowType.AUTOMATION),
output_field=models.CharField(),
)
)


class HogFlowListSummarySerializer(HogFlowSummarySerializer):
"""One row of the workflows list: the summary fields and the workflow type, without the step graph."""

type = serializers.ChoiceField(
source="workflow_type",
choices=HogFlowType.choices,
read_only=True,
help_text=(
"`loop` and `broadcast` for workflows those surfaces own. Otherwise `messaging` when the workflow "
"has an email, SMS or push step, else `automation`. The same rules as the `type` filter."
),
)

class Meta(HogFlowSummarySerializer.Meta):
fields = [*HogFlowSummarySerializer.Meta.fields, "type"]
read_only_fields = fields


BROADCAST_TRIGGER_TYPE = "batch"
BROADCAST_ALLOWED_ACTION_TYPES = frozenset({"trigger", "function_email", "exit"})

Expand Down Expand Up @@ -3972,50 +4017,60 @@ def mint_audience_confirm_token(
WRITABLE_DRAFT_CONTENT_FIELDS = frozenset(DRAFT_CONTENT_FIELDS) - frozenset(HogFlowSerializer.Meta.read_only_fields)


LIST_QUERY_PARAMETERS: Final[list[OpenApiParameter]] = [
OpenApiParameter(
"search",
OpenApiTypes.STR,
description="Case-insensitive search. Matches workflow name and description first; only when nothing matches those, it matches step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.",
),
OpenApiParameter(
"created_by",
OpenApiTypes.UUID,
description="Filter to workflows created by the user with this uuid.",
),
OpenApiParameter(
"type",
OpenApiTypes.STR,
description="Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest.",
),
OpenApiParameter(
"origin_product",
OpenApiTypes.STR,
enum=HogFlow.OriginProduct.values,
description="Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops.",
),
OpenApiParameter(
"trigger",
OpenApiTypes.STR,
description='Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}.',
),
OpenApiParameter(
"broadcast_eligible",
OpenApiTypes.BOOL,
description="Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step.",
),
]


SUMMARIES_QUERY_PARAMETERS: Final[list[OpenApiParameter]] = [
OpenApiParameter(
"search",
OpenApiTypes.STR,
description="Case-insensitive search over workflow name, description, step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.",
),
*(parameter for parameter in LIST_QUERY_PARAMETERS if parameter.name != "search"),
]


@extend_schema(extensions={"x-product": "workflows"})
@extend_schema_view(
list=extend_schema(
parameters=[
OpenApiParameter(
"search",
OpenApiTypes.STR,
description="Case-insensitive search. Matches workflow name and description first; only when nothing matches those, it matches step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.",
),
OpenApiParameter(
"created_by",
OpenApiTypes.UUID,
description="Filter to workflows created by the user with this uuid.",
),
OpenApiParameter(
"type",
OpenApiTypes.STR,
description="Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest.",
),
OpenApiParameter(
"origin_product",
OpenApiTypes.STR,
enum=HogFlow.OriginProduct.values,
description="Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops.",
),
OpenApiParameter(
"trigger",
OpenApiTypes.STR,
description='Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}.',
),
OpenApiParameter(
"broadcast_eligible",
OpenApiTypes.BOOL,
description="Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step.",
),
]
)
)
@extend_schema_view(list=extend_schema(parameters=LIST_QUERY_PARAMETERS))
class HogFlowViewSet(
TeamAndOrgViewSetMixin, AccessControlViewSetMixin, LogEntryMixin, AppMetricsMixin, viewsets.ModelViewSet
):
scope_object = "hog_flow"
scope_object_read_actions = [
"list",
"summaries",
"retrieve",
"logs",
"metrics",
Expand Down Expand Up @@ -4050,6 +4105,7 @@ class HogFlowViewSet(
]
queryset = HogFlow.objects.all()
pagination_class = HogFlowPagination
LIST_ACTIONS: Final = frozenset({"list", "summaries"})
filter_backends = [DjangoFilterBackend]
filterset_class = HogFlowFilterSet
log_source = "hog_flow"
Expand Down Expand Up @@ -4110,6 +4166,8 @@ def get_serializer_class(self) -> type[BaseSerializer]:
if self.request is not None and self._is_mcp_request(self.request):
return HogFlowSummarySerializer
return HogFlowMinimalSerializer
if self.action == "summaries":
return HogFlowListSummarySerializer
if self.action in ("update", "partial_update"):
return HogFlowUpdateSerializer
return HogFlowSerializer
Expand All @@ -4124,10 +4182,11 @@ def get_serializer_context(self) -> dict:
return context

def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
if self.action == "list":
# `id` breaks ties so LIMIT/OFFSET paging stays stable: rows sharing an updated_at can
# otherwise repeat on one page and never appear on another.
queryset = queryset.order_by("-updated_at", "-id")
if self.action in self.LIST_ACTIONS:
# `id` breaks ties so LIMIT/OFFSET paging stays stable. `summaries` loads every page, and a save
# during that load changes `updated_at`, so it sorts on `created_at`.
ordering = ("-created_at", "-id") if self.action == "summaries" else ("-updated_at", "-id")
queryset = queryset.order_by(*ordering).select_related("created_by")

created_by = self.request.GET.get("created_by")
if created_by:
Expand Down Expand Up @@ -4184,7 +4243,7 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet:
# Search runs after the filter backends so the tier decision below sees the same rows the response
# will: a name match that the `status` filter then drops must not stop the step search from running.
queryset = super().filter_queryset(queryset)
if self.action != "list":
if self.action not in self.LIST_ACTIONS:
return queryset

search = (self.request.GET.get("search") or "").strip()
Expand All @@ -4201,9 +4260,13 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet:
# name, so the common search stays cheap and a subject line or body text, which rarely appears in a
# workflow name, is still found.
by_name = Q(name__iregex=regex_pattern) | Q(description__iregex=regex_pattern)
by_content = Q(_action_content_matches(regex_pattern))
# `summaries` callers filter rows in the browser, so a name match must not hide the content matches.
if self.action == "summaries":
return queryset.filter(by_name | by_content)
if queryset.filter(by_name).exists():
return queryset.filter(by_name)
return queryset.filter(Q(_action_content_matches(regex_pattern)))
return queryset.filter(by_content)

def safely_get_object(self, queryset):
# TODO(team-workflows): Somehow implement version lookups
Expand All @@ -4213,6 +4276,24 @@ def safely_get_object(self, queryset):
def _is_mcp_request(request: Request) -> bool:
return request.headers.get("x-posthog-client") == "mcp"

@extend_schema(
summary="List workflow summaries",
description=(
"Workflow rows without the step graph, for loading a whole project's list page by page. "
"Sorted newest created first. Takes the same filters as the list."
),
parameters=SUMMARIES_QUERY_PARAMETERS,
responses={200: HogFlowListSummarySerializer(many=True)},
)
@action(detail=False, methods=["GET"], url_path="summaries")
def summaries(self, request: Request, *args: Any, **kwargs: Any) -> Response:
"""Applies the access-level filter itself: the routing mixin only applies it to `list`."""
queryset = self.get_queryset()
if not is_service_auth(request):
queryset = self.user_access_control.filter_queryset_by_access_level(queryset)
page = self.paginate_queryset(annotate_workflow_type(self.filter_queryset(queryset)))
return self.get_paginated_response(self.get_serializer(page, many=True).data)

@extend_schema(
request=HogInvocationRerunRequestSerializer,
responses={200: HogInvocationRerunResponseSerializer, 400: HogInvocationRerunResponseSerializer},
Expand Down
Loading