This is an automated email from the ASF dual-hosted git repository.
jasonliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new ab037cb0a01 Add external logs link (#49475)
ab037cb0a01 is described below
commit ab037cb0a01c2af9be7f3b3e2e3696234e3a3099
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Thu Apr 24 10:05:34 2025 +0800
Add external logs link (#49475)
* feat: support external log
* test: add test and dataModel
Co-Authored-By: LIU ZHE YOU <[email protected]>
* refactor: test structure
Co-Authored-By: LIU ZHE YOU <[email protected]>
* fix: airflowctl type in static check
* fix: api and ui
Co-authored-by: pierrejeambrun <[email protected]>
* fix: miss rel and coding style
* fix: replace a with `Link`
Co-authored-by: Brent Bovenzi <[email protected]>
---------
Co-authored-by: LIU ZHE YOU <[email protected]>
Co-authored-by: pierrejeambrun <[email protected]>
Co-authored-by: Brent Bovenzi <[email protected]>
---
.../airflow/api_fastapi/core_api/datamodels/log.py | 6 ++
.../api_fastapi/core_api/datamodels/ui/config.py | 2 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 9 +++
.../core_api/openapi/v1-rest-api-generated.yaml | 89 ++++++++++++++++++++++
.../api_fastapi/core_api/routes/public/log.py | 37 ++++++++-
.../api_fastapi/core_api/routes/ui/config.py | 4 +
.../src/airflow/ui/openapi-gen/queries/common.ts | 27 +++++++
.../ui/openapi-gen/queries/ensureQueryData.ts | 38 +++++++++
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 38 +++++++++
.../src/airflow/ui/openapi-gen/queries/queries.ts | 42 ++++++++++
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 42 ++++++++++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 29 +++++++
.../ui/openapi-gen/requests/services.gen.ts | 37 +++++++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 50 ++++++++++++
.../ui/src/pages/TaskInstance/ExtraLinks.tsx | 2 +-
.../pages/TaskInstance/Logs/ExternalLogLink.tsx | 71 +++++++++++++++++
.../ui/src/pages/TaskInstance/Logs/Logs.tsx | 15 ++++
.../api_fastapi/core_api/routes/public/test_log.py | 52 +++++++++++++
.../api_fastapi/core_api/routes/ui/test_config.py | 2 +
.../src/airflowctl/api/datamodels/generated.py | 8 ++
20 files changed, 598 insertions(+), 2 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
index e67264ae3c3..aa1298d1745 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
@@ -42,3 +42,9 @@ class TaskInstancesLogResponse(BaseModel):
content: list[StructuredLogMessage] | list[str]
"""Either a list of parsed events, or a list of lines on parse error"""
continuation_token: str | None
+
+
+class ExternalLogUrlResponse(BaseModel):
+ """Response for the external log URL endpoint."""
+
+ url: str
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
index e7c39b3f3a1..cb525918493 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
@@ -41,3 +41,5 @@ class ConfigResponse(BaseModel):
audit_view_included_events: str
test_connection: str
dashboard_alert: list[UIAlert]
+ show_external_log_redirect: bool
+ external_log_name: str | None = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 9c586c694b1..1d3bc9a5d06 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -764,6 +764,14 @@ components:
$ref: '#/components/schemas/UIAlert'
type: array
title: Dashboard Alert
+ show_external_log_redirect:
+ type: boolean
+ title: Show External Log Redirect
+ external_log_name:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: External Log Name
type: object
required:
- navbar_color
@@ -783,6 +791,7 @@ components:
- audit_view_included_events
- test_connection
- dashboard_alert
+ - show_external_log_redirect
title: ConfigResponse
description: configuration serializer.
ConnectionHookFieldBehavior:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
index c590ea0080b..79148509198 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
@@ -6417,6 +6417,85 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+
/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}:
+ get:
+ tags:
+ - Task Instance
+ summary: Get External Log Url
+ description: Get external log URL for a specific task instance.
+ operationId: get_external_log_url
+ security:
+ - OAuth2PasswordBearer: []
+ parameters:
+ - name: dag_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Dag Id
+ - name: dag_run_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Dag Run Id
+ - name: task_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Task Id
+ - name: try_number
+ in: path
+ required: true
+ schema:
+ type: integer
+ exclusiveMinimum: 0
+ title: Try Number
+ - name: map_index
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: -1
+ title: Map Index
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExternalLogUrlResponse'
+ '401':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Unauthorized
+ '403':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Forbidden
+ '400':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Bad Request
+ '404':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Not Found
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/api/v2/parseDagFile/{file_token}:
put:
tags:
@@ -8801,6 +8880,16 @@ components:
- extra
title: EventLogResponse
description: Event Log Response.
+ ExternalLogUrlResponse:
+ properties:
+ url:
+ type: string
+ title: Url
+ type: object
+ required:
+ - url
+ title: ExternalLogUrlResponse
+ description: Response for the external log URL endpoint.
ExtraLinkCollectionResponse:
properties:
extra_links:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
index 3483453d829..e95fcfe2300 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
@@ -30,7 +30,7 @@ from airflow.api_fastapi.common.db.common import SessionDep
from airflow.api_fastapi.common.headers import HeaderAcceptJsonOrText
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.common.types import Mimetype
-from airflow.api_fastapi.core_api.datamodels.log import
TaskInstancesLogResponse
+from airflow.api_fastapi.core_api.datamodels.log import
ExternalLogUrlResponse, TaskInstancesLogResponse
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import DagAccessEntity,
requires_access_dag
from airflow.exceptions import TaskNotFound
@@ -151,3 +151,38 @@ def get_log(
"Airflow-Continuation-Token":
URLSafeSerializer(request.app.state.secret_key).dumps(metadata)
}
return Response(media_type="application/x-ndjson", content=logs,
headers=headers)
+
+
+@task_instances_log_router.get(
+ "/{task_id}/externalLogUrl/{try_number}",
+ responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND]),
+ dependencies=[Depends(requires_access_dag("GET",
DagAccessEntity.TASK_INSTANCE))],
+)
+def get_external_log_url(
+ dag_id: str,
+ dag_run_id: str,
+ task_id: str,
+ try_number: PositiveInt,
+ session: SessionDep,
+ map_index: int = -1,
+) -> ExternalLogUrlResponse:
+ """Get external log URL for a specific task instance."""
+ task_log_reader = TaskLogReader()
+
+ if not task_log_reader.supports_external_link:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Task log handler
does not support external logs.")
+
+ # Fetch the task instance
+ query = select(TaskInstance).where(
+ TaskInstance.task_id == task_id,
+ TaskInstance.dag_id == dag_id,
+ TaskInstance.run_id == dag_run_id,
+ TaskInstance.map_index == map_index,
+ )
+ ti = session.scalar(query)
+
+ if ti is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "TaskInstance not
found")
+
+ url = task_log_reader.log_handler.get_external_log_url(ti, try_number)
+ return ExternalLogUrlResponse(url=url)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
index 11eefab1c85..2b797e64873 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
@@ -26,6 +26,7 @@ from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_
from airflow.api_fastapi.core_api.security import requires_access_configuration
from airflow.configuration import conf
from airflow.settings import DASHBOARD_UIALERTS
+from airflow.utils.log.log_reader import TaskLogReader
config_router = AirflowRouter(tags=["Config"])
@@ -56,12 +57,15 @@ def get_configs() -> ConfigResponse:
config = {key: conf_dict["webserver"].get(key) for key in
WEBSERVER_CONFIG_KEYS}
+ task_log_reader = TaskLogReader()
additional_config: dict[str, Any] = {
"instance_name": conf.get("webserver", "instance_name",
fallback="Airflow"),
"audit_view_included_events": conf.get("webserver",
"audit_view_included_events", fallback=""),
"audit_view_excluded_events": conf.get("webserver",
"audit_view_excluded_events", fallback=""),
"test_connection": conf.get("core", "test_connection",
fallback="Disabled"),
"dashboard_alert": DASHBOARD_UIALERTS,
+ "show_external_log_redirect": task_log_reader.supports_external_link,
+ "external_log_name": getattr(task_log_reader.log_handler, "log_name",
None),
}
config.update({key: value for key, value in additional_config.items()})
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index ec6efb8c19d..b67514b7de3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1228,6 +1228,33 @@ export const UseTaskInstanceServiceGetLogKeyFn = (
useTaskInstanceServiceGetLogKey,
...(queryKey ?? [{ accept, dagId, dagRunId, fullContent, mapIndex, taskId,
token, tryNumber }]),
];
+export type TaskInstanceServiceGetExternalLogUrlDefaultResponse = Awaited<
+ ReturnType<typeof TaskInstanceService.getExternalLogUrl>
+>;
+export type TaskInstanceServiceGetExternalLogUrlQueryResult<
+ TData = TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+ TError = unknown,
+> = UseQueryResult<TData, TError>;
+export const useTaskInstanceServiceGetExternalLogUrlKey =
"TaskInstanceServiceGetExternalLogUrl";
+export const UseTaskInstanceServiceGetExternalLogUrlKeyFn = (
+ {
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+ },
+ queryKey?: Array<unknown>,
+) => [
+ useTaskInstanceServiceGetExternalLogUrlKey,
+ ...(queryKey ?? [{ dagId, dagRunId, mapIndex, taskId, tryNumber }]),
+];
export type ImportErrorServiceGetImportErrorDefaultResponse = Awaited<
ReturnType<typeof ImportErrorService.getImportError>
>;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 29323892621..95f1377e712 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1697,6 +1697,44 @@ export const ensureUseTaskInstanceServiceGetLogData = (
tryNumber,
}),
});
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const ensureUseTaskInstanceServiceGetExternalLogUrlData = (
+ queryClient: QueryClient,
+ {
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+ },
+) =>
+ queryClient.ensureQueryData({
+ queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn({
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }),
+ queryFn: () => TaskInstanceService.getExternalLogUrl({ dagId, dagRunId,
mapIndex, taskId, tryNumber }),
+ });
/**
* Get Import Error
* Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index d9328eab70b..4cf302b0e5e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1697,6 +1697,44 @@ export const prefetchUseTaskInstanceServiceGetLog = (
tryNumber,
}),
});
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const prefetchUseTaskInstanceServiceGetExternalLogUrl = (
+ queryClient: QueryClient,
+ {
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+ },
+) =>
+ queryClient.prefetchQuery({
+ queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn({
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }),
+ queryFn: () => TaskInstanceService.getExternalLogUrl({ dagId, dagRunId,
mapIndex, taskId, tryNumber }),
+ });
/**
* Get Import Error
* Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index c05706beec8..5b3b77bb886 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2018,6 +2018,48 @@ export const useTaskInstanceServiceGetLog = <
}) as TData,
...options,
});
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const useTaskInstanceServiceGetExternalLogUrl = <
+ TData = Common.TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+ TError = unknown,
+ TQueryKey extends Array<unknown> = unknown[],
+>(
+ {
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+ },
+ queryKey?: TQueryKey,
+ options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+ useQuery<TData, TError>({
+ queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn(
+ { dagId, dagRunId, mapIndex, taskId, tryNumber },
+ queryKey,
+ ),
+ queryFn: () =>
+ TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, mapIndex,
taskId, tryNumber }) as TData,
+ ...options,
+ });
/**
* Get Import Error
* Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 3180bd87b93..b0033f55421 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1995,6 +1995,48 @@ export const useTaskInstanceServiceGetLogSuspense = <
}) as TData,
...options,
});
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const useTaskInstanceServiceGetExternalLogUrlSuspense = <
+ TData = Common.TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+ TError = unknown,
+ TQueryKey extends Array<unknown> = unknown[],
+>(
+ {
+ dagId,
+ dagRunId,
+ mapIndex,
+ taskId,
+ tryNumber,
+ }: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+ },
+ queryKey?: TQueryKey,
+ options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+ useSuspenseQuery<TData, TError>({
+ queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn(
+ { dagId, dagRunId, mapIndex, taskId, tryNumber },
+ queryKey,
+ ),
+ queryFn: () =>
+ TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, mapIndex,
taskId, tryNumber }) as TData,
+ ...options,
+ });
/**
* Get Import Error
* Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 142cc6db7b5..55bac5fa04f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -3046,6 +3046,19 @@ export const $EventLogResponse = {
description: "Event Log Response.",
} as const;
+export const $ExternalLogUrlResponse = {
+ properties: {
+ url: {
+ type: "string",
+ title: "Url",
+ },
+ },
+ type: "object",
+ required: ["url"],
+ title: "ExternalLogUrlResponse",
+ description: "Response for the external log URL endpoint.",
+} as const;
+
export const $ExtraLinkCollectionResponse = {
properties: {
extra_links: {
@@ -5866,6 +5879,21 @@ export const $ConfigResponse = {
type: "array",
title: "Dashboard Alert",
},
+ show_external_log_redirect: {
+ type: "boolean",
+ title: "Show External Log Redirect",
+ },
+ external_log_name: {
+ anyOf: [
+ {
+ type: "string",
+ },
+ {
+ type: "null",
+ },
+ ],
+ title: "External Log Name",
+ },
},
type: "object",
required: [
@@ -5886,6 +5914,7 @@ export const $ConfigResponse = {
"audit_view_included_events",
"test_connection",
"dashboard_alert",
+ "show_external_log_redirect",
],
title: "ConfigResponse",
description: "configuration serializer.",
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index b9c97a08128..48e8694a800 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -146,6 +146,8 @@ import type {
PatchTaskInstanceDryRun1Response,
GetLogData,
GetLogResponse,
+ GetExternalLogUrlData,
+ GetExternalLogUrlResponse,
GetImportErrorData,
GetImportErrorResponse,
GetImportErrorsData,
@@ -2548,6 +2550,41 @@ export class TaskInstanceService {
},
});
}
+
+ /**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+ public static getExternalLogUrl(data: GetExternalLogUrlData):
CancelablePromise<GetExternalLogUrlResponse> {
+ return __request(OpenAPI, {
+ method: "GET",
+ url:
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}",
+ path: {
+ dag_id: data.dagId,
+ dag_run_id: data.dagRunId,
+ task_id: data.taskId,
+ try_number: data.tryNumber,
+ },
+ query: {
+ map_index: data.mapIndex,
+ },
+ errors: {
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 422: "Validation Error",
+ },
+ });
+ }
}
export class ImportErrorService {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 6f3d2f0147b..39f89144f3e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -792,6 +792,13 @@ export type EventLogResponse = {
extra: string | null;
};
+/**
+ * Response for the external log URL endpoint.
+ */
+export type ExternalLogUrlResponse = {
+ url: string;
+};
+
/**
* Extra Links Response.
*/
@@ -1476,6 +1483,8 @@ export type ConfigResponse = {
audit_view_included_events: string;
test_connection: string;
dashboard_alert: Array<UIAlert>;
+ show_external_log_redirect: boolean;
+ external_log_name?: string | null;
};
/**
@@ -2391,6 +2400,16 @@ export type GetLogData = {
export type GetLogResponse = TaskInstancesLogResponse;
+export type GetExternalLogUrlData = {
+ dagId: string;
+ dagRunId: string;
+ mapIndex?: number;
+ taskId: string;
+ tryNumber: number;
+};
+
+export type GetExternalLogUrlResponse = ExternalLogUrlResponse;
+
export type GetImportErrorData = {
importErrorId: number;
};
@@ -4648,6 +4667,37 @@ export type $OpenApiTs = {
};
};
};
+
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}":
{
+ get: {
+ req: GetExternalLogUrlData;
+ res: {
+ /**
+ * Successful Response
+ */
+ 200: ExternalLogUrlResponse;
+ /**
+ * Bad Request
+ */
+ 400: HTTPExceptionResponse;
+ /**
+ * Unauthorized
+ */
+ 401: HTTPExceptionResponse;
+ /**
+ * Forbidden
+ */
+ 403: HTTPExceptionResponse;
+ /**
+ * Not Found
+ */
+ 404: HTTPExceptionResponse;
+ /**
+ * Validation Error
+ */
+ 422: HTTPValidationError;
+ };
+ };
+ };
"/api/v2/importErrors/{import_error_id}": {
get: {
req: GetImportErrorData;
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
index 878f06bb77c..efad2007b4e 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
@@ -38,7 +38,7 @@ export const ExtraLinks = () => {
{Object.entries(data.extra_links).map(([key, value], _) =>
value === null ? undefined : (
<Button asChild colorPalette="blue" key={key} variant="surface">
- <a href={value} rel="noreferrer" target="_blank">
+ <a href={value} rel="noopener noreferrer" target="_blank">
{key}
</a>
</Button>
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx
new file mode 100644
index 00000000000..f8bf4632b14
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx
@@ -0,0 +1,71 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Button, Link } from "@chakra-ui/react";
+import { useParams } from "react-router-dom";
+
+import { useTaskInstanceServiceGetExternalLogUrl } from "openapi/queries";
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+type Props = {
+ readonly externalLogName: string;
+ readonly taskInstance: TaskInstanceResponse;
+ readonly tryNumber: number;
+};
+
+export const ExternalLogLink = ({ externalLogName, taskInstance, tryNumber }:
Props) => {
+ const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
+
+ const {
+ data: externalLogData,
+ error,
+ isLoading,
+ } = useTaskInstanceServiceGetExternalLogUrl(
+ {
+ dagId,
+ dagRunId: runId,
+ mapIndex: parseInt(mapIndex, 10),
+ taskId,
+ tryNumber,
+ },
+ undefined,
+ {
+ enabled: Boolean(taskInstance) && Boolean(tryNumber) &&
Boolean(externalLogName),
+ retry: false,
+ },
+ );
+
+ if (Boolean(error) || isLoading || externalLogData?.url === undefined) {
+ return undefined;
+ }
+
+ return (
+ <Link
+ as={Button}
+ color="fg.info"
+ fontWeight="bold"
+ href={externalLogData.url}
+ py="2"
+ rel="noopener noreferrer"
+ target="_blank"
+ textDecoration="underline"
+ >
+ View logs in {externalLogName} (attempt {tryNumber})
+ </Link>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index 931caf50d6e..5373b22e2c0 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -26,6 +26,7 @@ import { SearchParamsKeys } from "src/constants/searchParams";
import { useConfig } from "src/queries/useConfig";
import { useLogs } from "src/queries/useLogs";
+import { ExternalLogLink } from "./ExternalLogLink";
import { TaskLogContent } from "./TaskLogContent";
import { TaskLogHeader } from "./TaskLogHeader";
@@ -83,6 +84,9 @@ export const Logs = () => {
tryNumber: tryNumber === 0 ? 1 : tryNumber,
});
+ const externalLogName = useConfig("external_log_name") as string;
+ const showExternalLogRedirect =
Boolean(useConfig("show_external_log_redirect"));
+
return (
<Box p={2}>
<TaskLogHeader
@@ -94,6 +98,17 @@ export const Logs = () => {
tryNumber={tryNumber}
wrap={wrap}
/>
+ {showExternalLogRedirect && externalLogName && taskInstance ? (
+ tryNumber === undefined ? (
+ <p>No try number</p>
+ ) : (
+ <ExternalLogLink
+ externalLogName={externalLogName}
+ taskInstance={taskInstance}
+ tryNumber={tryNumber}
+ />
+ )
+ ) : undefined}
<TaskLogContent
error={error}
isLoading={isLoading || isLoadingLogs}
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
index b391fc66492..d8908f68b94 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
@@ -397,3 +397,55 @@ class TestTaskInstancesLog:
)
assert response.status_code == 404
assert response.json()["detail"] == "TaskInstance not found"
+
+ @pytest.mark.parametrize(
+ "supports_external_link, task_id, expected_status, expected_response,
mock_external_url",
+ [
+ (
+ True,
+ "task_for_testing_log_endpoint",
+ 200,
+ {"url": "https://external-logs.example.com/log/123"},
+ True,
+ ),
+ (
+ False,
+ "task_for_testing_log_endpoint",
+ 400,
+ {"detail": "Task log handler does not support external logs."},
+ False,
+ ),
+ (True, "INVALID_TASK", 404, {"detail": "TaskInstance not found"},
False),
+ ],
+ ids=[
+ "external_links_supported_task_exists",
+ "external_links_not_supported",
+ "external_links_supported_task_not_found",
+ ],
+ )
+ def test_get_external_log_url(
+ self, supports_external_link, task_id, expected_status,
expected_response, mock_external_url
+ ):
+ with (
+ mock.patch(
+
"airflow.utils.log.log_reader.TaskLogReader.supports_external_link",
+ new_callable=mock.PropertyMock,
+ return_value=supports_external_link,
+ ),
+
mock.patch("airflow.utils.log.log_reader.TaskLogReader.log_handler") as
mock_log_handler,
+ ):
+ url =
f"/dags/{self.DAG_ID}/dagRuns/{self.RUN_ID}/taskInstances/{task_id}/externalLogUrl/{self.TRY_NUMBER}"
+ if mock_external_url:
+ mock_log_handler.get_external_log_url.return_value = (
+ "https://external-logs.example.com/log/123"
+ )
+
+ response = self.client.get(url)
+
+ if expected_status == 200:
+ mock_log_handler.get_external_log_url.assert_called_once()
+ else:
+ mock_log_handler.get_external_log_url.assert_not_called()
+
+ assert response.status_code == expected_status
+ assert response.json() == expected_response
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
index 399b8658df6..1228c505a27 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
@@ -40,6 +40,8 @@ mock_config_response = {
"audit_view_included_events": "",
"test_connection": "Disabled",
"dashboard_alert": [],
+ "show_external_log_redirect": False,
+ "external_log_name": None,
}
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index a44f3e4d8ee..2e0178680f6 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -483,6 +483,14 @@ class EventLogResponse(BaseModel):
extra: Annotated[str | None, Field(title="Extra")] = None
+class ExternalLogUrlResponse(BaseModel):
+ """
+ Response for the external log URL endpoint.
+ """
+
+ url: Annotated[str, Field(title="Url")]
+
+
class ExtraLinkCollectionResponse(BaseModel):
"""
Extra Links Response.