Skip to content

Commit 7160ab1

Browse files
committed
Add cre workflow logs command
Queries the GraphQL API for workflow execution history, showing timestamps, status, duration, and error details for failures. Supports --follow for continuous polling and --limit to control how many recent executions to display.
1 parent c448d8d commit 7160ab1

4 files changed

Lines changed: 656 additions & 2 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ func isLoadSettings(cmd *cobra.Command) bool {
261261
"cre login": {},
262262
"cre logout": {},
263263
"cre whoami": {},
264+
"cre workflow logs": {},
264265
"cre account list-key": {},
265266
"cre init": {},
266267
"cre generate-bindings": {},

cmd/workflow/logs/logs.go

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package logs
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"time"
8+
9+
"github.com/machinebox/graphql"
10+
"github.com/rs/zerolog"
11+
"github.com/spf13/cobra"
12+
13+
"github.com/smartcontractkit/cre-cli/internal/client/graphqlclient"
14+
"github.com/smartcontractkit/cre-cli/internal/credentials"
15+
"github.com/smartcontractkit/cre-cli/internal/environments"
16+
"github.com/smartcontractkit/cre-cli/internal/runtime"
17+
)
18+
19+
const pollInterval = 5 * time.Second
20+
21+
func New(runtimeContext *runtime.Context) *cobra.Command {
22+
var follow bool
23+
var limit int
24+
25+
logsCmd := &cobra.Command{
26+
Use: "logs <workflow-name>",
27+
Short: "Show execution history for a workflow",
28+
Long: "Fetches and displays recent execution history for the specified workflow from the CRE platform.",
29+
Args: cobra.ExactArgs(1),
30+
Example: ` cre workflow logs my-workflow
31+
cre workflow logs my-workflow --follow
32+
cre workflow logs my-workflow --limit 5`,
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
h := newHandler(runtimeContext, args[0], follow, limit)
35+
return h.Execute(cmd.Context())
36+
},
37+
}
38+
39+
logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Keep polling for new executions")
40+
logsCmd.Flags().IntVarP(&limit, "limit", "n", 10, "Number of recent executions to show")
41+
42+
return logsCmd
43+
}
44+
45+
type handler struct {
46+
log *zerolog.Logger
47+
credentials *credentials.Credentials
48+
environmentSet *environments.EnvironmentSet
49+
workflowName string
50+
follow bool
51+
limit int
52+
}
53+
54+
func newHandler(ctx *runtime.Context, workflowName string, follow bool, limit int) *handler {
55+
return &handler{
56+
log: ctx.Logger,
57+
credentials: ctx.Credentials,
58+
environmentSet: ctx.EnvironmentSet,
59+
workflowName: workflowName,
60+
follow: follow,
61+
limit: limit,
62+
}
63+
}
64+
65+
// GraphQL response types
66+
67+
type workflowsResponse struct {
68+
Workflows struct {
69+
Data []workflowEntry `json:"data"`
70+
Count int `json:"count"`
71+
} `json:"workflows"`
72+
}
73+
74+
type workflowEntry struct {
75+
UUID string `json:"uuid"`
76+
Name string `json:"name"`
77+
Status string `json:"status"`
78+
}
79+
80+
type executionsResponse struct {
81+
WorkflowExecutions struct {
82+
Data []execution `json:"data"`
83+
Count int `json:"count"`
84+
} `json:"workflowExecutions"`
85+
}
86+
87+
type execution struct {
88+
UUID string `json:"uuid"`
89+
Status string `json:"status"`
90+
StartedAt time.Time `json:"startedAt"`
91+
FinishedAt *time.Time `json:"finishedAt"`
92+
}
93+
94+
type eventsResponse struct {
95+
WorkflowExecutionEvents struct {
96+
Data []executionEvent `json:"data"`
97+
} `json:"workflowExecutionEvents"`
98+
}
99+
100+
type executionEvent struct {
101+
CapabilityID string `json:"capabilityID"`
102+
Status string `json:"status"`
103+
Errors []capError `json:"errors"`
104+
}
105+
106+
type capError struct {
107+
Error string `json:"error"`
108+
Count int `json:"count"`
109+
}
110+
111+
func (h *handler) Execute(ctx context.Context) error {
112+
client := graphqlclient.New(h.credentials, h.environmentSet, h.log)
113+
114+
workflowUUID, err := h.findWorkflow(ctx, client)
115+
if err != nil {
116+
return err
117+
}
118+
119+
fmt.Printf("\nWorkflow: %s\n\n", h.workflowName)
120+
121+
executions, err := h.fetchExecutions(ctx, client, workflowUUID)
122+
if err != nil {
123+
return err
124+
}
125+
126+
headerPrinted := false
127+
if len(executions) == 0 && !h.follow {
128+
fmt.Println("No executions found.")
129+
return nil
130+
}
131+
132+
if len(executions) > 0 {
133+
printHeader()
134+
headerPrinted = true
135+
h.printExecutions(ctx, client, executions)
136+
}
137+
138+
if !h.follow {
139+
return nil
140+
}
141+
142+
if !headerPrinted {
143+
fmt.Println("Waiting for executions...")
144+
}
145+
146+
lastSeenUUID := ""
147+
if len(executions) > 0 {
148+
lastSeenUUID = executions[0].UUID
149+
}
150+
151+
for {
152+
select {
153+
case <-time.After(pollInterval):
154+
case <-ctx.Done():
155+
return nil
156+
}
157+
158+
executions, err = h.fetchExecutions(ctx, client, workflowUUID)
159+
if err != nil {
160+
h.log.Error().Err(err).Msg("failed to fetch executions, retrying")
161+
continue
162+
}
163+
164+
newExecs := filterNew(executions, lastSeenUUID)
165+
if len(newExecs) > 0 {
166+
if !headerPrinted {
167+
printHeader()
168+
headerPrinted = true
169+
}
170+
h.printExecutions(ctx, client, newExecs)
171+
lastSeenUUID = executions[0].UUID
172+
}
173+
}
174+
}
175+
176+
func (h *handler) findWorkflow(ctx context.Context, client *graphqlclient.Client) (string, error) {
177+
req := graphql.NewRequest(`query FindWorkflow($input: WorkflowsInput!) {
178+
workflows(input: $input) {
179+
data { uuid name status }
180+
count
181+
}
182+
}`)
183+
req.Var("input", map[string]any{
184+
"search": h.workflowName,
185+
"page": map[string]int{"number": 0, "size": 20},
186+
})
187+
188+
var resp workflowsResponse
189+
if err := client.Execute(ctx, req, &resp); err != nil {
190+
return "", fmt.Errorf("failed to search for workflow: %w", err)
191+
}
192+
193+
for _, w := range resp.Workflows.Data {
194+
if w.Name == h.workflowName {
195+
return w.UUID, nil
196+
}
197+
}
198+
199+
if len(resp.Workflows.Data) == 0 {
200+
return "", fmt.Errorf("no workflow found matching %q", h.workflowName)
201+
}
202+
203+
names := make([]string, len(resp.Workflows.Data))
204+
for i, w := range resp.Workflows.Data {
205+
names[i] = w.Name
206+
}
207+
return "", fmt.Errorf("no exact match for %q; found: %s", h.workflowName, strings.Join(names, ", "))
208+
}
209+
210+
func (h *handler) fetchExecutions(ctx context.Context, client *graphqlclient.Client, workflowUUID string) ([]execution, error) {
211+
req := graphql.NewRequest(`query GetExecutions($input: WorkflowExecutionsInput!) {
212+
workflowExecutions(input: $input) {
213+
data { uuid status startedAt finishedAt }
214+
count
215+
}
216+
}`)
217+
req.Var("input", map[string]any{
218+
"workflowUuid": workflowUUID,
219+
"orderBy": map[string]string{"field": "STARTED_AT", "order": "DESC"},
220+
"page": map[string]int{"number": 0, "size": h.limit},
221+
})
222+
223+
var resp executionsResponse
224+
if err := client.Execute(ctx, req, &resp); err != nil {
225+
return nil, fmt.Errorf("failed to fetch executions: %w", err)
226+
}
227+
228+
return resp.WorkflowExecutions.Data, nil
229+
}
230+
231+
// filterNew returns executions that are newer than lastSeenUUID.
232+
// Executions are expected in DESC order (newest first).
233+
func filterNew(executions []execution, lastSeenUUID string) []execution {
234+
if lastSeenUUID == "" {
235+
return executions
236+
}
237+
for i, e := range executions {
238+
if e.UUID == lastSeenUUID {
239+
return executions[:i]
240+
}
241+
}
242+
// lastSeenUUID not found in current page, all are new
243+
return executions
244+
}
245+
246+
func printHeader() {
247+
fmt.Printf("%-24s %-12s %-10s %s\n", "TIMESTAMP", "STATUS", "DURATION", "EXECUTION ID")
248+
}
249+
250+
func (h *handler) printExecutions(ctx context.Context, client *graphqlclient.Client, executions []execution) {
251+
// Print oldest first (executions are in DESC order)
252+
for i := len(executions) - 1; i >= 0; i-- {
253+
e := executions[i]
254+
duration := "running"
255+
if e.FinishedAt != nil {
256+
duration = formatDuration(e.FinishedAt.Sub(e.StartedAt))
257+
}
258+
259+
fmt.Printf("%-24s %-12s %-10s %s\n",
260+
e.StartedAt.Format(time.RFC3339),
261+
strings.ToLower(e.Status),
262+
duration,
263+
shortUUID(e.UUID),
264+
)
265+
266+
if e.Status == "FAILURE" {
267+
h.printErrors(ctx, client, e.UUID)
268+
}
269+
}
270+
}
271+
272+
func (h *handler) printErrors(ctx context.Context, client *graphqlclient.Client, executionUUID string) {
273+
req := graphql.NewRequest(`query GetEvents($input: WorkflowExecutionEventsInput!) {
274+
workflowExecutionEvents(input: $input) {
275+
data { capabilityID status errors { error count } }
276+
}
277+
}`)
278+
req.Var("input", map[string]any{
279+
"workflowExecutionUUID": executionUUID,
280+
})
281+
282+
var resp eventsResponse
283+
if err := client.Execute(ctx, req, &resp); err != nil {
284+
h.log.Debug().Err(err).Msg("failed to fetch execution events")
285+
return
286+
}
287+
288+
for _, ev := range resp.WorkflowExecutionEvents.Data {
289+
if ev.Status == "failure" && len(ev.Errors) > 0 {
290+
errMsg := ev.Errors[0].Error
291+
if len(errMsg) > 120 {
292+
errMsg = errMsg[:120] + "..."
293+
}
294+
fmt.Printf(" -> %s: %s\n", ev.CapabilityID, errMsg)
295+
}
296+
}
297+
}
298+
299+
func formatDuration(d time.Duration) string {
300+
if d < time.Second {
301+
return fmt.Sprintf("%dms", d.Milliseconds())
302+
}
303+
return fmt.Sprintf("%.1fs", d.Seconds())
304+
}
305+
306+
func shortUUID(uuid string) string {
307+
if len(uuid) >= 8 {
308+
return uuid[:8]
309+
}
310+
return uuid
311+
}

0 commit comments

Comments
 (0)