Skip to content

Commit 0f37b40

Browse files
hari-kuriakoseclaudepre-commit-ci[bot]vishnuszipstackchandrasekharan-zipstack
authored
feat: Add HubSpot integration plugin hooks for user journey tracking (#1806)
* refactor: Add dynamic plugin loading for enterprise components ## What - Add dynamic plugin loading support to OSS codebase - Enable enterprise components to be loaded at runtime without modifying tracked files ## Why - Enterprise code was overwriting git-tracked OSS files causing dirty git state - Need clean separation between OSS and enterprise codebases - OSS should work independently without enterprise components ## How - `unstract_migrations.py`: Uses try/except ImportError to load from `pluggable_apps.migrations_ext` - `api_hub_usage_utils.py`: Uses try/except ImportError to load from `plugins.verticals_usage` - `utils.py`: Uses try/except ImportError to load from `pluggable_apps.manual_review_v2` and `plugins.workflow_manager.workflow_v2.rule_engine` - `backend.Dockerfile`: Conditional install of `requirements.txt` if present ## Can this PR break any existing features. If yes, please list possible items. If no, please explain why. - No. The changes add optional plugin loading that gracefully falls back to default behavior when plugins are not present. Existing OSS functionality is preserved. ## Database Migrations - None ## Env Config - None ## Relevant Docs - None ## Related Issues or PRs - None ## Dependencies Versions - None ## Notes on Testing - OSS build: Verify app starts and works without enterprise plugins - Enterprise build: Verify plugins are loaded and function correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: Use get_plugin() for API Hub usage utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Refactor random sampling logic in utils.py Removed redundant import of random and exception handling for manual_review_v2. Signed-off-by: Hari John Kuriakose <hari@zipstack.com> * fix: Add Traefik port labels and clean up service ignore list - Add explicit loadbalancer port labels for backend (8000) and frontend (3000) services in docker-compose to ensure proper Traefik routing - Rename spawned_services to ignored_services for clarity - Extend ignored_services list to include tool-classifier, tool-text_extractor, and worker-unified services that don't need environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Update frontend Docker config for nginx serving Update Traefik port label to 80 to match nginx and fix Dockerfile to use BUILD_CONTEXT_PATH for the runtime config script in both dev and prod stages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Use ARG instead of ENV for BUILD_CONTEXT_PATH in frontend Dockerfile Convert BUILD_CONTEXT_PATH from environment variable to build argument for proper Docker multi-stage build support. ARGs must be declared globally and re-declared in each stage that needs them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Add HubSpot integration plugin for contact event tracking - Add new integrations plugin category under backend/plugins/integrations/ - Create HubSpot plugin with event-based contact updates - Track user milestone events: project creation, document upload, prompt run, tool export, and API deployment - Plugin validates is_first_for_org flag and first org member status - Remove unused hubspot_signup_api() stub from authentication_service - Update subscription_helper to use plugin pattern for form submissions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * [FIX] Fix HITL review screen showing "Never expires" despite TTL being set (#1785) Fix two interacting bugs that prevented TTL from propagating to HITL queue records: 1. WorkflowUtil.get_hitl_ttl_seconds was an OSS stub that always returned None. Now delegates to get_hitl_ttl_seconds_by_workflow via try/except import, falling back to None in OSS environments. 2. _push_to_queue_for_api_deployment never fetched TTL. Now mirrors the connector path by calling WorkflowUtil.get_hitl_ttl_seconds and passing ttl_seconds through to _create_queue_result and _enqueue_to_packet_or_regular_queue. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Add dynamic plugin loading for enterprise components (#1736) * refactor: Add dynamic plugin loading for enterprise components - Add dynamic plugin loading support to OSS codebase - Enable enterprise components to be loaded at runtime without modifying tracked files - Enterprise code was overwriting git-tracked OSS files causing dirty git state - Need clean separation between OSS and enterprise codebases - OSS should work independently without enterprise components - `unstract_migrations.py`: Uses try/except ImportError to load from `pluggable_apps.migrations_ext` - `api_hub_usage_utils.py`: Uses try/except ImportError to load from `plugins.verticals_usage` - `utils.py`: Uses try/except ImportError to load from `pluggable_apps.manual_review_v2` and `plugins.workflow_manager.workflow_v2.rule_engine` - `backend.Dockerfile`: Conditional install of `requirements.txt` if present - No. The changes add optional plugin loading that gracefully falls back to default behavior when plugins are not present. Existing OSS functionality is preserved. - None - None - None - None - None - OSS build: Verify app starts and works without enterprise plugins - Enterprise build: Verify plugins are loaded and function correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: Use get_plugin() for API Hub usage utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Refactor random sampling logic in utils.py Removed redundant import of random and exception handling for manual_review_v2. Signed-off-by: Hari John Kuriakose <hari@zipstack.com> * fix: Add Traefik port labels and clean up service ignore list - Add explicit loadbalancer port labels for backend (8000) and frontend (3000) services in docker-compose to ensure proper Traefik routing - Rename spawned_services to ignored_services for clarity - Extend ignored_services list to include tool-classifier, tool-text_extractor, and worker-unified services that don't need environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Update frontend Docker config for nginx serving Update Traefik port label to 80 to match nginx and fix Dockerfile to use BUILD_CONTEXT_PATH for the runtime config script in both dev and prod stages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Use ARG instead of ENV for BUILD_CONTEXT_PATH in frontend Dockerfile Convert BUILD_CONTEXT_PATH from environment variable to build argument for proper Docker multi-stage build support. ARGs must be declared globally and re-declared in each stage that needs them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: typo in ignored services var name * fix: handle script execution via entrypoint --------- Signed-off-by: Hari John Kuriakose <hari@zipstack.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: Add auth error code for forbidden emails (#1789) * feat: add auth error code and frontend error display * fix: run frontend dev server on port 80 and add signup handler - Set PORT=80 env var in frontend Dockerfile development stage - Change EXPOSE from 3000 to 80 to match production nginx - Add handleSignup function and pass to LoginForm component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add ARG declaration to Dockerfile stages using BUILD_CONTEXT_PATH SonarQube flagged that ARG must be declared in each Docker build stage where it is used. Added the missing ARG BUILD_CONTEXT_PATH to both development and builder stages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * [MISC] Improve dev experience by adding a compose debug override (#1765) * [MISC] Improve Docker dev experience: separate debugpy, optimize memory, add V2 workers support - Move debugpy to optional compose.debug.yaml for cleaner default dev setup - Update compose.override.yaml with memory-optimized settings (1 worker, 2 threads) - Add V2 workers configuration with build definitions - Move V1 workers to optional workers-v1 profile - Use modern uv run python -Xfrozen_modules=off pattern for services - Updated README with compose.debug.yaml usage instructions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * [MISC] Add Docker Compose version requirement and scheduler comment - Add Docker Compose 2.24.4+ requirement note for !override directive - Add comment explaining why worker-log-history-scheduler-v2 has no watch Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * [MISC] Fix debug ports table in README - Fix port order: runner=5679, platform=5680, prompt=5681 - Remove x2text-service (not in compose.debug.yaml) - Add V2 workers debug ports (5682-5688) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * [MISC] Simplify sample.compose.override.yaml Remove db image override and V1 worker command overrides from sample file. Users should reference compose.override.yaml for actual dev setup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * [FIX] Optimize queries made by worker and retry config of worker base client (#1798) * Fix production queryset performance and retry amplification Resolves 39.8s GET /internal/v1/file-execution/<uuid>/ latency by: - Removing 7 debug COUNT(*) full table scans from get_queryset() - Adding get_object() O(1) PK lookup override in ViewSet - Adding @with_cache decorators to pipeline fetch methods - Adding pipeline_data_key() to prevent cache key collisions - Fixing urllib3 retry amplification: clear status_forcelist, let app-level retries handle status codes - Use config defaults instead of hardcoded retry values Root cause: Count queries on every request + retry storm (urllib3 × app-level). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR #1798 review comments - Chain exception context in get_object() (Ruff B904) - Fix cache key collision: get_workflow_definition() now uses CacheType.WORKFLOW_DEFINITION instead of CacheType.WORKFLOW to avoid type mismatch with get_workflow() sharing the same cache key - Include check_active in get_pipeline_data() cache key to prevent active-status bypass when check_active=False is cached first - Refactor status() and update_hash() to use self.get_object() instead of duplicating manual queryset + org filtering; add except APIException pass-through so NotFound propagates as 404 not 500 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * UN-2971 [FEAT] Pass selectedProduct to login/signup API for OAuth product scope (#1803) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Hari John Kuriakose <hari@zipstack.com> Co-authored-by: Claude <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: Scope HubSpot milestone count checks to current organization PromptStudioOutputManager and DocumentManager lack DefaultOrganizationManagerMixin, so .objects.count() was counting across ALL organizations. Filter through the tool FK to CustomTool (which is org-scoped) to get correct per-org counts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [REFACTOR] Extract HubSpot notification logic into shared utility Move all _notify_hubspot_* methods from views into a shared utils/hubspot_notify.py module with a single notify_hubspot_event() function, reducing duplication across prompt_studio views and api_deployment_views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Signed-off-by: Hari John Kuriakose <hari@zipstack.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: vishnuszipstack <117254672+vishnuszipstack@users.noreply.github.com> Co-authored-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> Co-authored-by: vishnuszipstack <vishnu@zipstack.com> Co-authored-by: Gayathri <142381512+gaya3-zipstack@users.noreply.github.com>
1 parent 063bc50 commit 0f37b40

4 files changed

Lines changed: 106 additions & 3 deletions

File tree

backend/account_v2/authentication_service.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,6 @@ def get_invitations(self, organization_id: str) -> list[MemberInvitation]:
266266
def frictionless_onboarding(self, organization: Organization, user: User) -> None:
267267
raise MethodNotImplemented()
268268

269-
def hubspot_signup_api(self, request: Request) -> None:
270-
raise MethodNotImplemented()
271-
272269
def delete_invitation(self, organization_id: str, invitation_id: str) -> bool:
273270
raise MethodNotImplemented()
274271

backend/api_v2/api_deployment_views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from rest_framework.serializers import Serializer
1717
from tool_instance_v2.models import ToolInstance
1818
from utils.enums import CeleryTaskState
19+
from utils.hubspot_notify import notify_hubspot_event
1920
from utils.pagination import CustomPagination
2021
from workflow_manager.workflow_v2.dto import ExecutionResponse
2122
from workflow_manager.workflow_v2.models.execution import WorkflowExecution
@@ -293,6 +294,9 @@ def fetch_one(self, request: Request, pk: str | None = None) -> Response:
293294
def create(
294295
self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any]
295296
) -> Response:
297+
# Check deployment count before create for HubSpot notification
298+
deployment_count_before = APIDeployment.objects.count()
299+
296300
serializer: Serializer = self.get_serializer(data=request.data)
297301
serializer.is_valid(raise_exception=True)
298302
self.perform_create(serializer)
@@ -301,6 +305,14 @@ def create(
301305
{"api_key": api_key.api_key, **serializer.data}
302306
)
303307

308+
# Notify HubSpot about first API deployment
309+
notify_hubspot_event(
310+
user=request.user,
311+
event_name="API_DEPLOY",
312+
is_first_for_org=deployment_count_before == 0,
313+
action_label="API deployment",
314+
)
315+
304316
headers = self.get_success_headers(serializer.data)
305317
return Response(
306318
response_serializer.data,

backend/prompt_studio/prompt_studio_core_v2/views.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from rest_framework.versioning import URLPathVersioning
2323
from tool_instance_v2.models import ToolInstance
2424
from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper
25+
from utils.hubspot_notify import notify_hubspot_event
2526
from utils.user_context import UserContext
2627
from utils.user_session import UserSessionUtils
2728
from workflow_manager.endpoint_v2.models import WorkflowEndpoint
@@ -57,6 +58,8 @@
5758
PromptStudioDocumentHelper,
5859
)
5960
from prompt_studio.prompt_studio_index_manager_v2.models import IndexManager
61+
from prompt_studio.prompt_studio_output_manager_v2.models import PromptStudioOutputManager
62+
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
6063
from prompt_studio.prompt_studio_registry_v2.prompt_studio_registry_helper import (
6164
PromptStudioRegistryHelper,
6265
)
@@ -119,6 +122,16 @@ def create(self, request: HttpRequest) -> Response:
119122
PromptStudioHelper.create_default_profile_manager(
120123
request.user, serializer.data["tool_id"]
121124
)
125+
126+
# Notify HubSpot if this is the first Prompt Studio project for the org
127+
# (count == 1 means the one we just created is the first)
128+
notify_hubspot_event(
129+
user=request.user,
130+
event_name="PROMPT_STUDIO_PROJECT_CREATE",
131+
is_first_for_org=CustomTool.objects.count() == 1,
132+
action_label="project creation",
133+
)
134+
122135
return Response(serializer.data, status=status.HTTP_201_CREATED)
123136

124137
def perform_destroy(self, instance: CustomTool) -> None:
@@ -409,6 +422,14 @@ def fetch_response(self, request: HttpRequest, pk: Any = None) -> Response:
409422
if not run_id:
410423
# Generate a run_id
411424
run_id = CommonUtils.generate_uuid()
425+
426+
# Check output count before prompt run for HubSpot notification
427+
# Filter through tool FK to scope by organization (PromptStudioOutputManager
428+
# lacks DefaultOrganizationManagerMixin)
429+
output_count_before = PromptStudioOutputManager.objects.filter(
430+
tool_id__in=CustomTool.objects.values_list("tool_id", flat=True)
431+
).count()
432+
412433
response: dict[str, Any] = PromptStudioHelper.prompt_responder(
413434
id=id,
414435
tool_id=tool_id,
@@ -418,6 +439,15 @@ def fetch_response(self, request: HttpRequest, pk: Any = None) -> Response:
418439
run_id=run_id,
419440
profile_manager_id=profile_manager,
420441
)
442+
443+
# Notify HubSpot about first prompt run
444+
notify_hubspot_event(
445+
user=request.user,
446+
event_name="PROMPT_RUN",
447+
is_first_for_org=output_count_before == 0,
448+
action_label="prompt run",
449+
)
450+
421451
return Response(response, status=status.HTTP_200_OK)
422452

423453
@action(detail=True, methods=["post"])
@@ -574,6 +604,13 @@ def upload_for_ide(self, request: HttpRequest, pk: Any = None) -> Response:
574604
uploaded_files: Any = serializer.validated_data.get("file")
575605
file_converter_plugin = get_plugin("file_converter")
576606

607+
# Check document count before upload for HubSpot notification
608+
# Filter through tool FK to scope by organization (DocumentManager
609+
# lacks DefaultOrganizationManagerMixin)
610+
doc_count_before = DocumentManager.objects.filter(
611+
tool__in=CustomTool.objects.all()
612+
).count()
613+
577614
documents = []
578615
for uploaded_file in uploaded_files:
579616
# Store file
@@ -624,6 +661,15 @@ def upload_for_ide(self, request: HttpRequest, pk: Any = None) -> Response:
624661
"tool": document.tool.tool_id,
625662
}
626663
documents.append(doc)
664+
665+
# Notify HubSpot about first document upload
666+
notify_hubspot_event(
667+
user=request.user,
668+
event_name="DOCUMENT_UPLOAD",
669+
is_first_for_org=doc_count_before == 0,
670+
action_label="document upload",
671+
)
672+
627673
return Response({"data": documents})
628674

629675
@action(detail=True, methods=["delete"])
@@ -675,13 +721,24 @@ def export_tool(self, request: Request, pk: Any = None) -> Response:
675721
user_ids = set(serializer.validated_data.get("user_id"))
676722
force_export = serializer.validated_data.get("force_export")
677723

724+
# Check registry count before export for HubSpot notification
725+
registry_count_before = PromptStudioRegistry.objects.count()
726+
678727
PromptStudioRegistryHelper.update_or_create_psr_tool(
679728
custom_tool=custom_tool,
680729
shared_with_org=is_shared_with_org,
681730
user_ids=user_ids,
682731
force_export=force_export,
683732
)
684733

734+
# Notify HubSpot about first tool export
735+
notify_hubspot_event(
736+
user=request.user,
737+
event_name="TOOL_EXPORT",
738+
is_first_for_org=registry_count_before == 0,
739+
action_label="tool export",
740+
)
741+
685742
return Response(
686743
{"message": "Custom tool exported sucessfully."},
687744
status=status.HTTP_200_OK,

backend/utils/hubspot_notify.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import logging
2+
3+
from plugins import get_plugin
4+
5+
logger = logging.getLogger(__name__)
6+
7+
8+
def notify_hubspot_event(
9+
user,
10+
event_name: str,
11+
is_first_for_org: bool,
12+
action_label: str,
13+
) -> None:
14+
"""Send a HubSpot event notification if the plugin is available.
15+
16+
Args:
17+
user: The user performing the action.
18+
event_name: The HubSpotEvent attribute name (e.g. "PROMPT_RUN").
19+
is_first_for_org: Whether this is the first such action for the org.
20+
action_label: Human-readable label for logging (e.g. "prompt run").
21+
"""
22+
hubspot_plugin = get_plugin("hubspot")
23+
if not hubspot_plugin:
24+
return
25+
26+
try:
27+
from plugins.integrations.hubspot import HubSpotEvent
28+
29+
event = getattr(HubSpotEvent, event_name)
30+
service = hubspot_plugin["service_class"]()
31+
service.update_contact(
32+
user=user,
33+
events=[event],
34+
is_first_for_org=is_first_for_org,
35+
)
36+
except Exception as e:
37+
logger.warning(f"Failed to notify HubSpot for {action_label}: {e}")

0 commit comments

Comments
 (0)