Repository: temporalio/ui
Pull request #2623: feat: add raw JSON history download endpoint
Author: login: rossnelson
Created: 2025-03-27T21:53:43Z | Merged: 2025-03-31T17:51:42Z

## Description & motivation 💭 <!-- Describe what has changed in this PR and the motivation behind it -->

Add a new endpoint to download the raw workflow history as a JSON file.
This enables easy offline analysis and replaying of workflow histories.

The changes include:
- Backend Go implementation for streaming the workflow history JSON
- New API endpoint at /namespaces/:namespace/workflows/:workflow/run/:runid/history.json
- Frontend UI component to access the raw history download
- Integration with existing workflow history UI

### Screenshots (if applicable) 📸 <!-- Add screenshots or videos -->

https://github.com/user-attachments/assets/19b28ab4-9a5a-47cc-819c-73bd5238a558

### Design Considerations 🎨 <!-- Any questions, concerns, thoughts for Design? -->

## Testing 🧪 <!-- Describe what has changed in this PR and the motivation behind it -->

### How was this tested 👻 <!--- Please describe how you tested your changes and tests that were added -->

- [x] Manual testing
- [ ] E2E tests added
- [x] Unit tests added

### Steps for others to test: 🚶🏽‍♂️🚶🏽‍♀️ <!--- Please describe how we can test the changes in the PR -->

## Checklists

### Draft Checklist <!-- Add todos if not ready to review -->

### Merge Checklist <!-- Add todos if not ready to merge -->

### Issue(s) closed <!-- add issue number here -->

## Docs

### Any docs updates needed? <!--- Update README if applicable or point out where to update docs.temporal.io -->



Diff:
diff --git a/server/go.mod b/server/go.mod
index 321001606c..1901d196ca 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -29,6 +29,7 @@ require (
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasttemplate v1.2.1 // indirect
 	golang.org/x/crypto v0.35.0 // indirect
diff --git a/server/go.sum b/server/go.sum
index c0f733e167..e1c1dd764d 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -42,6 +42,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
diff --git a/server/server/api/rawhistory.go b/server/server/api/rawhistory.go
new file mode 100644
index 0000000000..5b5b08f86a
--- /dev/null
+++ b/server/server/api/rawhistory.go
@@ -0,0 +1,173 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/labstack/echo/v4"
+	"go.temporal.io/api/common/v1"
+	"go.temporal.io/api/workflowservice/v1"
+	"golang.org/x/net/context"
+	"google.golang.org/grpc"
+)
+
+const WorkflowRawHistoryUrl = "/namespaces/:namespace/workflows/:workflow/run/:runid/history.json"
+
+func WorkflowRawHistoryHandler(service IWorkflowService) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		// Set headers for JSON streaming
+		c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+		c.Response().WriteHeader(http.StatusOK)
+
+		// // Stream the history events
+		return NewRawHistory().
+			SetIDs(
+				c.Param("namespace"),
+				c.Param("workflow"),
+				c.Param("runid"),
+			).
+			SetContext(c.Request().Context()).
+			SetWriter(c.Response().Writer).
+			SetWorkflowService(service).
+			StreamEvents()
+	}
+}
+
+type IWorkflowService interface {
+	GetWorkflowExecutionHistory(
+		ctx context.Context,
+		in *workflowservice.GetWorkflowExecutionHistoryRequest,
+		opts ...grpc.CallOption,
+	) (*workflowservice.GetWorkflowExecutionHistoryResponse, error)
+}
+
+func NewRawHistory() *RawHistory {
+	return &RawHistory{}
+}
+
+type RawHistory struct {
+	namespace  string
+	workflowID string
+	runID      string
+
+	writtenCount int
+
+	context context.Context
+	writer  http.ResponseWriter
+	encoder *json.Encoder
+	service IWorkflowService
+}
+
+func (rh *RawHistory) SetIDs(
+	namespace string,
+	workflowID string,
+	runID string,
+) *RawHistory {
+	rh.namespace = namespace
+	rh.workflowID = workflowID
+	rh.runID = runID
+
+	return rh
+}
+
+func (rh *RawHistory) SetContext(c context.Context) *RawHistory {
+	rh.context = c
+
+	return rh
+}
+
+func (rh *RawHistory) SetWriter(w http.ResponseWriter) *RawHistory {
+	rh.writer = w
+	rh.encoder = json.NewEncoder(rh.writer)
+
+	return rh
+}
+
+func (rh *RawHistory) SetWorkflowService(service IWorkflowService) *RawHistory {
+	rh.service = service
+
+	return rh
+}
+
+func (rh *RawHistory) StreamEvents() error {
+	// Write opening of response object
+	fmt.Fprint(rh.writer, "[")
+
+	// Write the history events
+	err := rh.streamEvents()
+	if err != nil {
+		rh.handleError(err)
+	}
+
+	fmt.Fprint(rh.writer, "]")
+	return nil
+}
+
+// streamEvents handles the actual event streaming with pagination
+func (rh *RawHistory) streamEvents() error {
+	var nextPageToken []byte
+	isFirstEvent := true
+
+	writer := rh.writer
+	encoder := rh.encoder
+
+	for {
+		// Get a page of history
+		response, err := rh.fetchPage(nextPageToken)
+		if err != nil {
+			return err
+		}
+
+		// Stream each event in this page
+		for _, event := range response.History.Events {
+			// Add comma separator between events
+			if !isFirstEvent {
+				fmt.Fprint(writer, ",")
+			} else {
+				isFirstEvent = false
+			}
+
+			// Write event directly to response
+			if err := encoder.Encode(event); err != nil {
+				return err
+			}
+
+			// Flush to ensure streaming
+			writer.(http.Flusher).Flush()
+			rh.writtenCount++
+		}
+
+		// Check if we're done
+		nextPageToken = response.NextPageToken
+		if len(nextPageToken) == 0 {
+			break
+		}
+	}
+
+	return nil
+}
+
+// fetchHistoryPage gets a single page of workflow history
+func (rh *RawHistory) fetchPage(
+	nextPageToken []byte,
+) (*workflowservice.GetWorkflowExecutionHistoryResponse, error) {
+	request := &workflowservice.GetWorkflowExecutionHistoryRequest{
+		Namespace: rh.namespace,
+		Execution: &common.WorkflowExecution{
+			WorkflowId: rh.workflowID,
+			RunId:      rh.runID,
+		},
+		NextPageToken: nextPageToken,
+	}
+
+	return rh.service.GetWorkflowExecutionHistory(rh.context, request)
+}
+
+func (rh *RawHistory) handleError(err error) {
+	if rh.writtenCount > 0 {
+		fmt.Fprint(rh.writer, ",")
+	}
+
+	fmt.Fprintf(rh.writer, `{"error": "%s"}`, err)
+}
diff --git a/server/server/api/rawhistory_test.go b/server/server/api/rawhistory_test.go
new file mode 100644
index 0000000000..962bfb5d85
--- /dev/null
+++ b/server/server/api/rawhistory_test.go
@@ -0,0 +1,117 @@
+package api_test
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/temporalio/ui-server/v2/server/api"
+
+	"github.com/labstack/echo/v4"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"go.temporal.io/api/history/v1"
+	"go.temporal.io/api/workflowservice/v1"
+	"google.golang.org/grpc"
+)
+
+// MockWorkflowService is a mock implementation of IWorkflowService
+type MockWorkflowService struct {
+	mock.Mock
+}
+
+func (m *MockWorkflowService) GetWorkflowExecutionHistory(
+	ctx context.Context,
+	req *workflowservice.GetWorkflowExecutionHistoryRequest,
+	opts ...grpc.CallOption,
+) (*workflowservice.GetWorkflowExecutionHistoryResponse, error) {
+	args := m.Called(ctx, req)
+	return args.Get(0).(*workflowservice.GetWorkflowExecutionHistoryResponse), args.Error(1)
+}
+
+func TestWorkflowRawHistoryHandler_HappyPath(t *testing.T) {
+	e := echo.New()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec)
+
+	c.SetParamNames("namespace", "workflow", "runid")
+	c.SetParamValues("test-namespace", "test-workflow", "test-runid")
+
+	mockService := new(MockWorkflowService)
+	mockService.On("GetWorkflowExecutionHistory", mock.Anything, mock.Anything).
+		Return(&workflowservice.GetWorkflowExecutionHistoryResponse{
+			History: &history.History{
+				Events: []*history.HistoryEvent{
+					{EventId: 1, EventType: 1},
+				},
+			},
+		}, nil)
+
+	handler := api.WorkflowRawHistoryHandler(mockService)
+
+	err := handler(c)
+
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, rec.Code)
+	assert.JSONEq(t, `[{"Attributes": null, "event_id":1, "event_type":1}]`, rec.Body.String())
+	mockService.AssertExpectations(t)
+}
+
+func TestRawHistory_StreamEvents_ErrorHandling(t *testing.T) {
+	mockService := new(MockWorkflowService)
+	mockService.On("GetWorkflowExecutionHistory", mock.Anything, mock.Anything).
+		Return(&workflowservice.GetWorkflowExecutionHistoryResponse{
+			History: &history.History{
+				Events: []*history.HistoryEvent{
+					{EventId: 1, EventType: 1},
+				},
+			},
+		}, fmt.Errorf("fetch error"))
+
+	rec := httptest.NewRecorder()
+	rh := api.NewRawHistory().
+		SetIDs("test-namespace", "test-workflow", "test-runid").
+		SetContext(context.Background()).
+		SetWriter(rec).
+		SetWorkflowService(mockService)
+
+	err := rh.StreamEvents()
+
+	assert.NoError(t, err)
+	assert.JSONEq(t, `[{"error": "fetch error"}]`, rec.Body.String())
+	mockService.AssertExpectations(t)
+}
+
+func TestRawHistory_StreamEvents_HappyPath(t *testing.T) {
+	mockService := new(MockWorkflowService)
+	mockService.On("GetWorkflowExecutionHistory", mock.Anything, mock.Anything).
+		Return(&workflowservice.GetWorkflowExecutionHistoryResponse{
+			History: &history.History{
+				Events: []*history.HistoryEvent{
+					{EventId: 1, EventType: 1},
+					{EventId: 2, EventType: 5},
+				},
+			},
+			NextPageToken: nil,
+		}, nil)
+
+	rec := httptest.NewRecorder()
+	rh := api.NewRawHistory().
+		SetIDs("test-namespace", "test-workflow", "test-runid").
+		SetContext(context.Background()).
+		SetWriter(rec).
+		SetWorkflowService(mockService)
+
+	err := rh.StreamEvents()
+
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`[{"Attributes": null, "event_id":1,"event_type":1},{"Attributes": null, "event_id":2,"event_type":5}]`,
+		rec.Body.String(),
+	)
+	mockService.AssertExpectations(t)
+}
diff --git a/server/server/route/api.go b/server/server/route/api.go
index 0bdd289b98..6460e6d019 100644
--- a/server/server/route/api.go
+++ b/server/server/route/api.go
@@ -28,6 +28,7 @@ import (
 	"strings"
 
 	"github.com/labstack/echo/v4"
+	"go.temporal.io/api/workflowservice/v1"
 
 	"github.com/temporalio/ui-server/v2/server/api"
 	"github.com/temporalio/ui-server/v2/server/config"
@@ -70,11 +71,16 @@ func SetAPIRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh, a
 
 	writeControlMiddleware := DisableWriteMiddleware(cfgProvider)
 	conn, err := api.CreateGRPCConnection(cfgProvider)
-
 	if err != nil {
 		return fmt.Errorf("Failed to create gRPC connection to Temporal server: %w", err)
 	}
 
+	route.GET(
+		api.WorkflowRawHistoryUrl,
+		api.WorkflowRawHistoryHandler(workflowservice.NewWorkflowServiceClient(conn)),
+		writeControlMiddleware,
+	)
+
 	route.Match([]string{"GET", "POST", "PUT", "PATCH", "DELETE"}, "/*", api.TemporalAPIHandler(cfgProvider, apiMiddleware, conn), writeControlMiddleware)
 
 	// New api paths with removed prefix. Need to figure out how to handle when ui and ui server are on same host
diff --git a/src/lib/components/workflow-raw-history-link.svelte b/src/lib/components/workflow-raw-history-link.svelte
new file mode 100644
index 0000000000..3c393c9972
--- /dev/null
+++ b/src/lib/components/workflow-raw-history-link.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+  import Link from '$lib/holocene/link.svelte';
+  import { translate } from '$lib/i18n/translate';
+  import { isCloud } from '$lib/stores/advanced-visibility';
+  import { parameters } from '$lib/stores/events';
+  import { routeForWorkflowHistoryJson } from '$lib/utilities/route-for';
+
+  const { namespace, workflowId, runId } = $parameters;
+
+  $: href = routeForWorkflowHistoryJson({
+    namespace,
+    workflow: workflowId,
+    run: runId,
+  });
+</script>
+
+{#if !$isCloud}
+  <div class="flex items-center space-x-4">
+    <Link
+      icon="external-link"
+      class="whitespace-nowrap"
+      {href}
+      newTab={true}
+      data-testid="view-raw-event-history"
+    >
+      {translate('events.view-raw-history')}
+    </Link>
+  </div>
+{/if}
diff --git a/src/lib/components/workflow/download-event-history-modal.svelte b/src/lib/components/workflow/download-event-history-modal.svelte
index 3c9dbd273b..24cea3a769 100644
--- a/src/lib/components/workflow/download-event-history-modal.svelte
+++ b/src/lib/components/workflow/download-event-history-modal.svelte
@@ -8,6 +8,8 @@
   import { downloadEventHistorySetting } from '$lib/stores/events';
   import { exportHistory } from '$lib/utilities/export-history';
 
+  import WorkflowRawHistoryLink from '../workflow-raw-history-link.svelte';
+
   export let open = false;
   export let namespace: string;
   export let workflowId: string;
@@ -65,5 +67,7 @@
         description={translate('events.readable-description')}
       />
     </RadioGroup>
+
+    <WorkflowRawHistoryLink />
   </div>
 </Modal>
diff --git a/src/lib/i18n/locales/en/events.ts b/src/lib/i18n/locales/en/events.ts
index 7653bdd749..288fbd01e2 100644
--- a/src/lib/i18n/locales/en/events.ts
+++ b/src/lib/i18n/locales/en/events.ts
@@ -93,4 +93,5 @@ export const Strings = {
   'readable-description': 'Codec Server decoded and base64 decoded',
   'event-types': 'Event Types',
   'decode-failed': 'Decoding failed',
+  'view-raw-history': 'View Raw History',
 } as const;
diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts
index 3b3ea56296..78e1a0d4f3 100644
--- a/src/lib/services/events-service.ts
+++ b/src/lib/services/events-service.ts
@@ -39,6 +39,22 @@ export type FetchEventsParametersWithSettings = FetchEventsParameters & {
   accessToken: string;
 };
 
+export const getEndpointForRawHistory = ({
+  namespace,
+  workflowId,
+  runId,
+}: FetchEventsParameters): string => {
+  return routeForApi(
+    'events.raw',
+    {
+      namespace,
+      workflowId,
+      runId,
+    },
+    false,
+  );
+};
+
 const getEndpointForSortOrder = (
   sortOrder: EventSortOrder,
 ): WorkflowAPIRoutePath => {
diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts
index 9ce76ab032..0ae03ddc27 100644
--- a/src/lib/types/api.ts
+++ b/src/lib/types/api.ts
@@ -10,6 +10,7 @@ export type WorkflowAPIRoutePath =
   | 'workflow.terminate'
   | 'workflow.cancel'
   | 'workflow.reset'
+  | 'events.raw'
   | 'events.ascending'
   | 'events.descending';
 
@@ -74,6 +75,8 @@ export type APIRouteParameters = {
   namespace: string;
   workflowId: string;
   scheduleId: string;
+  // feel like this might not be the "right" spot for this.
+  runId: string;
   batchJobId: string;
   queue: string;
   queryType: string;
@@ -108,6 +111,11 @@ export type WorkflowRouteParameters = Pick<
   'namespace' | 'workflowId'
 >;
 
+export type WorkflowRawHistoryRouteParameters = Pick<
+  APIRouteParameters,
+  'namespace' | 'workflowId' | 'runId'
+>;
+
 export type WorkflowQueryRouteParameters = Pick<
   APIRouteParameters,
   'namespace' | 'workflowId' | 'queryType'
diff --git a/src/lib/utilities/route-for-api.ts b/src/lib/utilities/route-for-api.ts
index 5f67cf23d7..c8517977bc 100644
--- a/src/lib/utilities/route-for-api.ts
+++ b/src/lib/utilities/route-for-api.ts
@@ -34,6 +34,7 @@ import type {
   WorkflowListRouteParameters,
   WorkflowQueryAPIRoutePath,
   WorkflowQueryRouteParameters,
+  WorkflowRawHistoryRouteParameters,
   WorkflowRouteParameters,
   WorkflowsAPIRoutePath,
   WorkflowSignalAPIRoutePath,
@@ -109,6 +110,7 @@ const encode = (
       signalName: '',
       updateName: '',
       batchJobId: '',
+      runId: '',
       activityId: '',
       endpointId: '',
       deploymentName: '',
@@ -130,6 +132,7 @@ export function pathForApi(
     namespaces: '/namespaces',
     namespace: `/namespaces/${parameters?.namespace}`,
     'search-attributes': `/namespaces/${parameters.namespace}/search-attributes`,
+    'events.raw': `/namespaces/${parameters?.namespace}/workflows/${parameters?.workflowId}/run/${parameters?.runId}/history.json`,
     'events.ascending': `/namespaces/${parameters?.namespace}/workflows/${parameters?.workflowId}/history`,
     'events.descending': `/namespaces/${parameters?.namespace}/workflows/${parameters?.workflowId}/history-reverse`,
     query: `/namespaces/${parameters?.namespace}/workflows/${parameters?.workflowId}/query/${parameters.queryType}`,
@@ -191,6 +194,11 @@ export function routeForApi(
   parameters: WorkflowRouteParameters,
   shouldEncode?: boolean,
 ): string;
+export function routeForApi(
+  route: WorkflowAPIRoutePath,
+  parameters: WorkflowRawHistoryRouteParameters,
+  shouldEncode?: boolean,
+): string;
 export function routeForApi(
   route: WorkflowSignalAPIRoutePath,
   parameters: WorkflowSignalRouteParameters,
diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts
index 163d096418..90b0de00e1 100644
--- a/src/lib/utilities/route-for.ts
+++ b/src/lib/utilities/route-for.ts
@@ -131,6 +131,14 @@ export const routeForWorkflow = ({
   return `${routeForWorkflows(parameters)}/${wid}/${run}`;
 };
 
+export const routeForWorkflowHistoryJson = ({
+  workflow,
+  run,
+  ...parameters
+}: WorkflowParameters): string => {
+  return `${routeForWorkflows(parameters)}/${workflow}/${run}/history.json`;
+};
+
 export const routeForSchedules = (parameters: NamespaceParameter): string => {
   return `${routeForNamespace(parameters)}/schedules`;
 };
diff --git a/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/history.json/+page.ts b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/history.json/+page.ts
new file mode 100644
index 0000000000..2f6b3218d4
--- /dev/null
+++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/history.json/+page.ts
@@ -0,0 +1,17 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageLoad } from './$types';
+
+import { getEndpointForRawHistory } from '$lib/services/events-service';
+
+export const load: PageLoad = async function ({ parent, params }) {
+  await parent();
+
+  const { namespace, workflow, run } = params;
+  const route = getEndpointForRawHistory({
+    namespace,
+    workflowId: workflow,
+    runId: run,
+  });
+  redirect(302, route);
+};


Reviews:
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T21:59:21Z
user: login: Alex-Tideman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T14:31:17Z
user: login: Alex-Tideman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T14:32:58Z
user: login: Alex-Tideman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T14:35:12Z
user: login: Alex-Tideman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T14:38:52Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:16:29Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:19:35Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:19:51Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:20:19Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:25:12Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:33:12Z
user: login: Alex-Tideman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T17:20:34Z
user: login: rossnelson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T17:21:30Z
user: login: Alex-Tideman
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-31T17:48:58Z

Review comments:
diff_hunk: @@ -63,19 +63,34 @@ func DisableWriteMiddleware(cfgProvider *config.ConfigProviderWithRefresh) echo.
 }
 
 // SetAPIRoutes sets api routes
-func SetAPIRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh, apiMiddleware []api.Middleware) error {
+func SetAPIRoutes(
+	e *echo.Echo,
+	cfgProvider *config.ConfigProviderWithRefresh,
+	apiMiddleware []api.Middleware,
+) error {
 
 	route := e.Group("/api/v1")
 	route.GET("/settings", api.GetSettings(cfgProvider))
 
 	writeControlMiddleware := DisableWriteMiddleware(cfgProvider)
 	conn, err := api.CreateGRPCConnection(cfgProvider)
 
+	route.GET(
+		api.WorkflowRawHistoryUrl,
+		api.WorkflowRawHistoryHandler(cfgProvider, conn),
+		writeControlMiddleware,
+	)
+
 	if err != nil {
 		return fmt.Errorf("Failed to create gRPC connection to Temporal server: %w", err)
 	}
 
-	route.Match([]string{"GET", "POST", "PUT", "PATCH", "DELETE"}, "/*", api.TemporalAPIHandler(cfgProvider, apiMiddleware, conn), writeControlMiddleware)
+	route.Match(
+		[]string{"GET", "POST", "PUT", "PATCH", "DELETE"},
+		"/*",
+		api.TemporalAPIHandler(cfgProvider, apiMiddleware, conn),
+		writeControlMiddleware,
+	)
path: server/server/route/api.go
user: login: rossnelson
body: ugh, the formatter got heavy handed. ill fix this. 
created_at: 2025-03-27T21:59:21Z
author_association: COLLABORATOR
diff_hunk: @@ -71,6 +72,15 @@ func SetAPIRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh, a
 	writeControlMiddleware := DisableWriteMiddleware(cfgProvider)
 	conn, err := api.CreateGRPCConnection(cfgProvider)
 
+	route.GET(
+		api.WorkflowRawHistoryUrl,
+		api.WorkflowRawHistoryHandler(
+			cfgProvider,
+			workflowservice.NewWorkflowServiceClient(conn),
+		),
+		writeControlMiddleware,
+	)
+
path: server/server/route/api.go
user: login: Alex-Tideman
body: Probs should put this after the if err != nil check
created_at: 2025-03-28T14:31:17Z
author_association: COLLABORATOR
diff_hunk: @@ -65,5 +67,7 @@
         description={translate('events.readable-description')}
       />
     </RadioGroup>
+
+    <WorkflowRawHistoryLink />
path: src/lib/components/workflow/download-event-history-modal.svelte
user: login: Alex-Tideman
body: What was the reasoning on putting in the download modal instead of on JSON page? I could be convinced either way, just curious on adding it here when it's not really downloading
created_at: 2025-03-28T14:32:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,15 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageLoad } from './$types';
+
+import { getEndpointForRawHistory } from '$lib/services/events-service';
+
+export const load: PageLoad = async function ({ params }) {
path: src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/history.json/+page.ts
user: login: Alex-Tideman
body: Just thinking through this auth flow, the child page endpoint get called first, so we might need ({ params, parent }) {

await parent(); => This would await top-level layout.ts and trig the auth redirect
created_at: 2025-03-28T14:35:11Z
author_association: COLLABORATOR
diff_hunk: @@ -29,6 +29,7 @@ require (
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
path: server/go.mod
user: login: Alex-Tideman
body: What's this used for?
created_at: 2025-03-28T14:38:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,15 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageLoad } from './$types';
+
+import { getEndpointForRawHistory } from '$lib/services/events-service';
+
+export const load: PageLoad = async function ({ params }) {
path: src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/history.json/+page.ts
user: login: rossnelson
body: Perfect! Great tip. 
created_at: 2025-03-28T15:16:29Z
author_association: COLLABORATOR
diff_hunk: @@ -65,5 +67,7 @@
         description={translate('events.readable-description')}
       />
     </RadioGroup>
+
+    <WorkflowRawHistoryLink />
path: src/lib/components/workflow/download-event-history-modal.svelte
user: login: rossnelson
body: I went back and forth on it. 

It seemed strange to have a download modal in one location and a link to what is essentially the same data in another. 

I started to think of the download modal as a "share sheet" 

![CleanShot 2025-03-28 at 11 17 22](https://github.com/user-attachments/assets/5ed22457-ac45-418c-9950-4ec6734899ab)

created_at: 2025-03-28T15:19:35Z
author_association: COLLABORATOR
diff_hunk: @@ -65,5 +67,7 @@
         description={translate('events.readable-description')}
       />
     </RadioGroup>
+
+    <WorkflowRawHistoryLink />
path: src/lib/components/workflow/download-event-history-modal.svelte
user: login: rossnelson
body: I have no strong opinion either way though. 
created_at: 2025-03-28T15:19:51Z
author_association: COLLABORATOR
diff_hunk: @@ -71,6 +72,15 @@ func SetAPIRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh, a
 	writeControlMiddleware := DisableWriteMiddleware(cfgProvider)
 	conn, err := api.CreateGRPCConnection(cfgProvider)
 
+	route.GET(
+		api.WorkflowRawHistoryUrl,
+		api.WorkflowRawHistoryHandler(
+			cfgProvider,
+			workflowservice.NewWorkflowServiceClient(conn),
+		),
+		writeControlMiddleware,
+	)
+
path: server/server/route/api.go
user: login: rossnelson
body: 😬 for sure...
created_at: 2025-03-28T15:20:19Z
author_association: COLLABORATOR
diff_hunk: @@ -29,6 +29,7 @@ require (
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
path: server/go.mod
user: login: rossnelson
body: its a dependency of the [mock lib](https://github.com/temporalio/ui/pull/2623/files#diff-46e7b151aa03f35df855459d9c92526f2bb861deb0cfac6df195b4fe9aa844efR14)
created_at: 2025-03-28T15:25:12Z
author_association: COLLABORATOR
diff_hunk: @@ -29,6 +29,7 @@ require (
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
path: server/go.mod
user: login: rossnelson
body: One of the nice things about go is that there are no dev/prod deps. Packages are included based on what application packages are included. When using `_test` on a test file, that file is not included in the compiled binary. In turn, none of its dependencies are included either. 

So the mock lib and all its deps, while part of the sum, are not included in the compiled binary. 
created_at: 2025-03-28T15:33:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+<script lang="ts">
+  import Link from '$lib/holocene/link.svelte';
+  import { translate } from '$lib/i18n/translate';
+  import { isCloud } from '$lib/stores/advanced-visibility';
+  import { parameters } from '$lib/stores/events';
+  import { routeForEventHistory } from '$lib/utilities/route-for';
+
+  const { namespace, workflowId, runId } = $parameters;
+
+  $: href = routeForEventHistory({
+    namespace,
+    workflow: workflowId,
+    run: runId,
+  });
+  $: linkToRedirect = `${href}.json`;
+</script>
+
+{#if !isCloud}
path: src/lib/components/workflow-raw-history-link.svelte
user: login: Alex-Tideman
body: This needs to be !$isCloud (i.e the value of the store, not the store itself)
created_at: 2025-03-28T17:20:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+<script lang="ts">
+  import Link from '$lib/holocene/link.svelte';
+  import { translate } from '$lib/i18n/translate';
+  import { isCloud } from '$lib/stores/advanced-visibility';
+  import { parameters } from '$lib/stores/events';
+  import { routeForEventHistory } from '$lib/utilities/route-for';
+
+  const { namespace, workflowId, runId } = $parameters;
+
+  $: href = routeForEventHistory({
+    namespace,
+    workflow: workflowId,
+    run: runId,
+  });
+  $: linkToRedirect = `${href}.json`;
+</script>
+
+{#if !isCloud}
path: src/lib/components/workflow-raw-history-link.svelte
user: login: rossnelson
body: Ahh, I'll get used to svelte syntax shortly.
created_at: 2025-03-28T17:21:30Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-27T21:53:47Z
body: [vc]: #tm2VmCyYug0PNTicTFwW1eh9H74xbEq90oYqo9nKmv0=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJob2xvY2VuZSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJob2xvY2VuZS1naXQtZHQtMjg0MS1yZXBsYXlhYmxlLWhpc3RvcnktanNvbi5wcmV2aWV3LnRodW5kZXJndW4uaW8ifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RlbXBvcmFsL2hvbG9jZW5lLzI5bzVHcXNycWl6TVRqa29ScFBaclJMclpyZjEiLCJwcmV2aWV3VXJsIjoiaG9sb2NlbmUtZ2l0LWR0LTI4NDEtcmVwbGF5YWJsZS1oaXN0b3J5LWpzb24ucHJldmlldy50aHVuZGVyZ3VuLmlvIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **holocene** | ✅ Ready ([Inspect](https://vercel.com/temporal/holocene/29o5GqsrqizMTjkoRpPZrRLrZrf1)) | [Visit Preview](https://holocene-git-dt-2841-replayable-history-json.preview.thundergun.io) | 💬 [**Add feedback**](https://vercel.live/open-feedback/holocene-git-dt-2841-replayable-history-json.preview.thundergun.io?via=pr-comment-feedback-link) | Mar 28, 2025 8:08pm |


author_association: NONE
user: login: CLAassistant
created_at: 2025-03-27T21:53:49Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/temporalio/ui?pullRequest=2623) <br/>All committers have signed the CLA.
author_association: NONE
user: login: Alex-Tideman
created_at: 2025-03-28T17:11:50Z
body: <img width="1918" alt="Screenshot 2025-03-28 at 12 11 05 PM" src="https://github.com/user-attachments/assets/4bbd28ac-2381-4fc4-a8f0-742f697813b0" />

And so begins your introduction to our url encoding nightmare
author_association: COLLABORATOR
user: login: Alex-Tideman
created_at: 2025-03-28T17:15:22Z
body: Looks like there's an extra %25 encoding added

Download url:
http://localhost:8081/api/v1/namespaces/canary/workflows/temporal.canary.cron-workflow.sanity-2025-03-28T12%253A10%253A49-05%253A00%252Fworkflow.retry-workflow-external-try1/run/4cda95ac-f2b1-4cf9-97c5-81d9cdad329d/history.json

UI History request
http://localhost:8081/api/v1/namespaces/canary/workflows/temporal.canary.cron-workflow.sanity-2025-03-28T12%3A10%3A49-05%3A00%2Fworkflow.retry-workflow-external-try1/history?execution.runId=4cda95ac-f2b1-4cf9-97c5-81d9cdad329d

author_association: COLLABORATOR
user: login: Alex-Tideman
created_at: 2025-03-28T17:16:19Z
body: I think the issue is using the routeForEventHistory, when the params have already been encoded
author_association: COLLABORATOR