Skip to content
Open
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
21 changes: 21 additions & 0 deletions acceptance/experimental/air/help/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ Global Flags:
-o, --output type output type: text or json (default text)
-p, --profile string ~/.databrickscfg profile
-t, --target string bundle target to use (if applicable)

=== logs help
>>> [CLI] experimental air logs --help
Stream logs from an active run, or fetch logs from a completed run.

Usage:
databricks experimental air logs JOB_RUN_ID [flags]

Flags:
--download-to string Download all logs to this directory instead of printing
-h, --help help for logs
--lines int For completed runs, print the last N lines (default 10000)
--minutes int Fetch only logs from the last N minutes
--node int Fetch logs from this node
--retry int View logs from a specific retry attempt; -1 means latest (default -1)

Global Flags:
--debug enable debug logging
-o, --output type output type: text or json (default text)
-p, --profile string ~/.databrickscfg profile
-t, --target string bundle target to use (if applicable)
3 changes: 3 additions & 0 deletions acceptance/experimental/air/help/script
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ trace $CLI experimental air --help

title "list help"
trace $CLI experimental air list --help

title "logs help"
trace $CLI experimental air logs --help

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

12 changes: 12 additions & 0 deletions acceptance/experimental/air/logs-mlflow-fallback/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

=== logs falls back to mlflow (no logs)
>>> [CLI] experimental air logs 123
No logs available for run 123. Run terminated in state SUCCESS

Exit code: 1

=== logs falls back to mlflow (json)
>>> [CLI] experimental air logs 123 -o json
{"type":"ERROR","ts":"[TIMESTAMP]","node":0,"line":"No logs available for run 123. Run terminated in state SUCCESS"}

Exit code: 1
9 changes: 9 additions & 0 deletions acceptance/experimental/air/logs-mlflow-fallback/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Bricklens is gated off (FEATURE_DISABLED), so the command falls back to the
# MLflow log path. With no MLflow run id resolvable, the fallback reports no
# logs and exits non-zero — proving the try/catch routes to MLflow.

title "logs falls back to mlflow (no logs)"
errcode trace $CLI experimental air logs 123

title "logs falls back to mlflow (json)"
errcode trace $CLI experimental air logs 123 -o json
32 changes: 32 additions & 0 deletions acceptance/experimental/air/logs-mlflow-fallback/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# The SDK occasionally probes host reachability with a HEAD request; stub it so
# the test is deterministic.
[[Server]]
Pattern = "HEAD /"
Response.Body = ''

# A completed run.
[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get"
Response.Body = '''
{
"run_id": 123,
"start_time": 1700000000000,
"end_time": 1700000012000,
"state": {"life_cycle_state": "TERMINATED", "result_state": "SUCCESS"},
"tasks": [{"task_key": "train", "run_id": 456, "attempt_number": 0}]
}
'''

# Bricklens is gated off by the backend SAFE flag, forcing the MLflow fallback.
[[Server]]
Pattern = "GET /api/2.0/ai-training/workflows/by-run-id/123/logs"
Response.StatusCode = 403
Response.Body = '''
{"error_code": "FEATURE_DISABLED", "message": "training log streaming is not enabled"}
'''

# The MLflow fallback has no run output to resolve an MLflow run id from, so it
# reports no logs rather than failing — exercising the fallback wiring end to end.
[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get-output"
Response.Body = '{}'
3 changes: 3 additions & 0 deletions acceptance/experimental/air/logs/out.test.toml

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

57 changes: 57 additions & 0 deletions acceptance/experimental/air/logs/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@

=== logs (text, completed run)
>>> [CLI] experimental air logs 123
step 1
step 2

=== logs (json)
>>> [CLI] experimental air logs 123 -o json
{"type":"LOG","ts":"[TIMESTAMP]","node":0,"line":"step 1"}
{"type":"LOG","ts":"[TIMESTAMP]","node":0,"line":"step 2"}

=== logs with --minutes
>>> [CLI] experimental air logs 123 --minutes 30
step 1
step 2

=== logs with --lines
>>> [CLI] experimental air logs 123 --lines 1
step 2

=== logs from a specific retry
>>> [CLI] experimental air logs 123 --retry 0
step 1
step 2

=== logs --lines and --minutes are mutually exclusive
>>> [CLI] experimental air logs 123 --lines 100 --minutes 30
Error: cannot combine --lines with --minutes: --lines tails by line count, --minutes by time window

Exit code: 1

=== logs --lines and --minutes are mutually exclusive (json)
>>> [CLI] experimental air logs 123 --lines 100 --minutes 30 -o json
{
"v": 1,
"ts": "[TIMESTAMP]",
"error": {
"code": "INVALID_ARGS",
"kind": "PERMANENT",
"message": "cannot combine --lines with --minutes: --lines tails by line count, --minutes by time window",
"retryable": false
}
}

Exit code: 1

=== invalid run id
>>> [CLI] experimental air logs notanumber
Error: invalid JOB_RUN_ID "notanumber": must be a positive integer

Exit code: 1

=== --download-to not implemented
>>> [CLI] experimental air logs 123 --download-to /tmp/out
Error: --download-to is not implemented yet

Exit code: 1
26 changes: 26 additions & 0 deletions acceptance/experimental/air/logs/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
title "logs (text, completed run)"
trace $CLI experimental air logs 123

title "logs (json)"
trace $CLI experimental air logs 123 -o json

title "logs with --minutes"
trace $CLI experimental air logs 123 --minutes 30

title "logs with --lines"
trace $CLI experimental air logs 123 --lines 1

title "logs from a specific retry"
trace $CLI experimental air logs 123 --retry 0

title "logs --lines and --minutes are mutually exclusive"
errcode trace $CLI experimental air logs 123 --lines 100 --minutes 30

title "logs --lines and --minutes are mutually exclusive (json)"
errcode trace $CLI experimental air logs 123 --lines 100 --minutes 30 -o json

title "invalid run id"
errcode trace $CLI experimental air logs notanumber

title "--download-to not implemented"
errcode trace $CLI experimental air logs 123 --download-to /tmp/out
29 changes: 29 additions & 0 deletions acceptance/experimental/air/logs/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# The SDK occasionally probes host reachability with a HEAD request; stub it so
# the test is deterministic.
[[Server]]
Pattern = "HEAD /"
Response.Body = ''

# A completed run: Bricklens serves its logs via the tail drain.
[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get"
Response.Body = '''
{
"run_id": 123,
"start_time": 1700000000000,
"end_time": 1700000012000,
"state": {"life_cycle_state": "TERMINATED", "result_state": "SUCCESS"},
"tasks": [{"task_key": "train", "run_id": 456, "attempt_number": 0}]
}
'''

# Bricklens log records, returned newest-first (as the tail fetch requests);
# printed oldest-first.
[[Server]]
Pattern = "GET /api/2.0/ai-training/workflows/by-run-id/123/logs"
Response.Body = '''
{"log_records": [
{"time_unix_nano": 1700000002000000000, "body": "step 2", "node_index": 0},
{"time_unix_nano": 1700000001000000000, "body": "step 1", "node_index": 0}
]}
'''
6 changes: 0 additions & 6 deletions acceptance/experimental/air/unimplemented/output.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@

=== logs
>>> [CLI] experimental air logs 123
Error: `air logs` is not implemented yet

Exit code: 1

=== register-image
>>> [CLI] experimental air register-image my-image:latest
Error: `air register-image` is not implemented yet
Expand Down
3 changes: 0 additions & 3 deletions acceptance/experimental/air/unimplemented/script
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
# Each stub must fail with "not implemented"; errcode records the exit code.

title "logs"
errcode trace $CLI experimental air logs 123

title "register-image"
errcode trace $CLI experimental air register-image my-image:latest
93 changes: 93 additions & 0 deletions experimental/air/cmd/logbricklens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package aircmd

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"

"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/client"
)

// bricklensLogsPathFmt is the log endpoint, keyed by Jobs run id. Called with a
// raw client.Do because the SDK does not model the AiTrainingService.
const bricklensLogsPathFmt = "/api/2.0/ai-training/workflows/by-run-id/%d/logs"

// logRecord is one log line from Bricklens.
type logRecord struct {
// TimeUnixNano may arrive as a JSON number or string.
TimeUnixNano json.Number `json:"time_unix_nano"`
Body string `json:"body"`
NodeIndex int `json:"node_index"`
}

// nano returns time_unix_nano as an int64, or 0 when absent or unparseable.
func (r logRecord) nano() int64 {
n, err := r.TimeUnixNano.Int64()
if err != nil {
return 0
}
return n
}

type bricklensLogsResponse struct {
LogRecords []logRecord `json:"log_records"`
NextPageToken string `json:"next_page_token"`
}

// bricklensLogsQuery is the request-field surface of the log endpoint.
// Zero-valued optionals are omitted so the endpoint applies its own defaults.
type bricklensLogsQuery struct {
// fromSeconds and toSeconds bound the query window in Unix epoch seconds.
fromSeconds int64
toSeconds int64
pageToken string
pageSize int
// attemptNumber selects a retry attempt (0-indexed); -1 means latest.
attemptNumber int
nodeIndex int
// ascending returns oldest-first. The endpoint defaults to ascending when
// absent, so the tail fetch must send an explicit false for newest-first.
ascending bool
}

// getBricklensLogs fetches one page of logs. It returns the raw error so the
// caller can classify it via classifyLogError.
func getBricklensLogs(ctx context.Context, w *databricks.WorkspaceClient, runID int64, q bricklensLogsQuery) (*bricklensLogsResponse, error) {
apiClient, err := client.New(w.Config)
if err != nil {
return nil, fmt.Errorf("failed to create API client: %w", err)
}

query := map[string]any{
// Always sent: the tail path relies on an explicit false for newest-first.
"ascending": strconv.FormatBool(q.ascending),
}
if q.fromSeconds > 0 {
query["from"] = q.fromSeconds
}
if q.toSeconds > 0 {
query["to"] = q.toSeconds
}
if q.pageToken != "" {
query["page_token"] = q.pageToken
}
if q.pageSize > 0 {
query["page_size"] = q.pageSize
}
if q.attemptNumber >= 0 {
query["ref.attempt_number"] = q.attemptNumber
}
if q.nodeIndex >= 0 {
query["filter.node_index"] = q.nodeIndex
}

var resp bricklensLogsResponse
path := fmt.Sprintf(bricklensLogsPathFmt, runID)
if err := apiClient.Do(ctx, http.MethodGet, path, nil, nil, query, &resp); err != nil {
return nil, err
}
return &resp, nil
}
Loading
Loading