This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 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 71e74bc9554 Add deadline callback log visibility to the Dag Run view 
in UI (#73580)
71e74bc9554 is described below

commit 71e74bc9554bd8783050c662af2881e70267ad22
Author: Sean Ghaeli <[email protected]>
AuthorDate: Sun Oct 18 00:49:53 2026 -0700

    Add deadline callback log visibility to the Dag Run view in UI (#73580)
    
    * Show deadline callback logs in a Callbacks tab on the Dag run view
    
    Add a Callbacks tab to the Dag run view, shown only when the run has
    deadline callbacks. It lists them (path, type, state, deadline alert,
    deadline time, created at) from the existing deadlines endpoint, which
    now also returns each deadline's callback id, type, state and path, and
    links each one to its logs, rendered with the task instance log viewer.
    
    The new GET 
/ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs
    reads a callback's logs from remote storage or the local log folder. The
    triggerer now writes async callback output to triggerer_callbacks/... so
    those logs can be read too.
    
    * Show deadline and alert details above callback logs
    
    Above a callback's logs, show the deadline alert and its rule, the
    deadline time, how late the run was, and the callback type, state and
    path, all from the existing deadlines and deadline alerts endpoints.
    In the Callbacks tab, replace the Created At column with Missed by and
    show callback types as Sync/Async.
    
    * Show the callback's row from the Callbacks table above its logs
    
    * Even out spacing above the callback log header
    
    * Trim callback log UI and tests
    
    * Explain why the callback log reader prefers stream() over read()
    
    * Make the callback log reader generic
    
    Replace read_callback_log with read_logs_at_paths, which reads logs not
    tied to a TaskInstance from the given paths. The callback logs route now
    builds the executor/triggerer callback paths itself.
    
    * Type callback columns for react-table v9
    
    * Give each triggerer callback its own log directory
    
    With [logging] delete_local_logs=True, the S3, GCS, OSS, CloudWatch and
    Elasticsearch handlers delete the uploaded log file's parent directory.
    Triggerer callback logs sat directly in the per-Dag-run directory, so the
    first callback to finish deleted the local logs of every other callback
    still running in the same Dag run, and those were never uploaded.
    
    * Expand callback logs in place in the Callbacks tab
    
    Clicking Logs on a callback now opens its logs in a row under it,
    instead of going to a separate page that repeated the callback's row.
    That page also looked the callback up in the first page of the list,
    so it lost the summary for callbacks past that page; it is removed.
    
    DataTable gains optional getRowId and renderSubComponent props, using
    TanStack's row expanding feature.
    
    * Drop the isFetching prop from the Callbacks table
    
    main removed isFetching from DataTable in #74426 ("Drop isFetching from
    tables"), which this branch's new Callbacks tab still passed, so tsc
    failed after the merge with TS2322 on Callbacks.tsx:154.
    
    * Read callback logs only at the path for the callback's type
    
    CloudWatch returns a lazy stream for any log stream name and reports a 
missing
    stream as a log line, so trying executor_callbacks/ first always won and 
async
    (triggerer) callback logs showed "No log stream found" instead of their log.
    
    * Let remote log handlers read logs without a task instance
    
    Widen RemoteLogIO.read and RemoteLogStreamIO.stream to accept ti=None, 
matching
    upload, so callback logs no longer need a type: ignore. Elasticsearch, 
OpenSearch
    and Cloud Logging key logs by task instance, so they now return no logs for
    ti=None instead of raising AttributeError.
    
    * Fix callback log tests on SQLite by passing a UUID to the callback id 
filter
    
    The Uuid column's SQLite bind processor rejects a str, so set_callback_type
    failed with "'str' object has no attribute 'hex'".
---
 .../api_fastapi/core_api/datamodels/ui/deadline.py |   7 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  | 146 +++++++++++++++-
 .../api_fastapi/core_api/routes/ui/deadlines.py    |  75 +++++++-
 .../src/airflow/jobs/triggerer_job_runner.py       |  16 +-
 .../src/airflow/ui/openapi-gen/queries/common.ts   |   9 +
 .../ui/openapi-gen/queries/ensureQueryData.ts      |  19 ++-
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |  19 ++-
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |  19 ++-
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |  19 ++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  39 ++++-
 .../ui/openapi-gen/requests/services.gen.ts        |  35 +++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  43 ++++-
 .../airflow/ui/public/i18n/locales/en/common.json  |   1 +
 .../src/airflow/ui/public/i18n/locales/en/dag.json |  16 ++
 .../DataTable/DataTable.expanding.test.tsx         |  74 ++++++++
 .../ui/src/components/DataTable/DataTable.tsx      |  15 +-
 .../ui/src/components/DataTable/TableList.tsx      |  32 +++-
 .../ui/src/components/DataTable/features.ts        |   5 +-
 .../ui/src/components/renderStructuredLog.test.tsx |  18 +-
 .../ui/src/components/renderStructuredLog.tsx      |   3 +-
 .../airflow/ui/src/pages/Run/CallbackLogs.test.tsx |  85 +++++++++
 .../src/airflow/ui/src/pages/Run/CallbackLogs.tsx  | 189 +++++++++++++++++++++
 .../airflow/ui/src/pages/Run/Callbacks.test.tsx    | 139 +++++++++++++++
 .../src/airflow/ui/src/pages/Run/Callbacks.tsx     | 161 ++++++++++++++++++
 .../src/airflow/ui/src/pages/Run/Run.test.tsx      |  81 +++++++++
 airflow-core/src/airflow/ui/src/pages/Run/Run.tsx  |  35 ++--
 .../src/airflow/ui/src/queries/useLogs.tsx         |  45 ++++-
 airflow-core/src/airflow/ui/src/router.tsx         |   2 +
 .../src/airflow/utils/log/path_log_reader.py       |  98 +++++++++++
 .../core_api/routes/ui/test_deadlines.py           | 125 +++++++++++++-
 .../tests/unit/api_fastapi/core_api/test_app.py    |   1 +
 airflow-core/tests/unit/jobs/test_triggerer_job.py |  31 ++++
 .../tests/unit/utils/log/test_path_log_reader.py   |  76 +++++++++
 .../alibaba/cloud/log/oss_task_handler.py          |   2 +-
 .../amazon/aws/log/cloudwatch_task_handler.py      |   6 +-
 .../providers/amazon/aws/log/s3_task_handler.py    |   2 +-
 .../providers/apache/hdfs/log/hdfs_task_handler.py |   2 +-
 .../providers/elasticsearch/log/es_task_handler.py |   5 +-
 .../unit/elasticsearch/log/test_es_task_handler.py |   3 +
 .../google/cloud/log/cloud_logging_task_handler.py |   5 +-
 .../providers/google/cloud/log/gcs_task_handler.py |   4 +-
 .../cloud/log/test_cloud_logging_task_handler.py   |   5 +
 .../microsoft/azure/log/wasb_task_handler.py       |   2 +-
 .../providers/opensearch/log/os_task_handler.py    |   5 +-
 .../unit/opensearch/log/test_os_task_handler.py    |   3 +
 .../logging/src/airflow_shared/logging/remote.py   |  16 +-
 46 files changed, 1675 insertions(+), 63 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
index 53b583d1f40..0ea484817ac 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
@@ -25,6 +25,7 @@ from uuid import UUID
 from pydantic import AliasPath, Field, field_validator
 
 from airflow.api_fastapi.core_api.base import BaseModel
+from airflow.utils.state import CallbackState
 
 
 class DeadlineResponse(BaseModel):
@@ -39,6 +40,12 @@ class DeadlineResponse(BaseModel):
     alert_id: UUID | None = Field(validation_alias="deadline_alert_id", 
default=None)
     alert_name: str | None = 
Field(validation_alias=AliasPath("deadline_alert", "name"), default=None)
     team_name: str | None = Field(validation_alias=AliasPath("dagrun", 
"team_name"), default=None)
+    callback_id: UUID
+    callback_type: str = Field(validation_alias=AliasPath("callback", "type"))
+    callback_state: CallbackState | None = Field(
+        validation_alias=AliasPath("callback", "state"), default=None
+    )
+    callback_path: str | None = Field(validation_alias=AliasPath("callback", 
"data", "path"), default=None)
 
 
 class DeadlineCollectionResponse(BaseModel):
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 150e27d3beb..f939caedacd 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
@@ -1329,13 +1329,15 @@ paths:
             type: string
           description: 'Attributes to order by, multi criteria sort is 
supported.
             Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time,
-            created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`'
+            created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state,
+            callback_type`'
           default:
           - deadline_time
           title: Order By
         description: 'Attributes to order by, multi criteria sort is 
supported. Prefix
           with `-` for descending order. Supported attributes: `id, 
deadline_time,
-          created_at, last_updated_at, missed, dag_id, dag_run_id, alert_name`'
+          created_at, last_updated_at, missed, dag_id, dag_run_id, alert_name, 
callback_state,
+          callback_type`'
       - name: missed
         in: query
         required: false
@@ -1524,6 +1526,80 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs:
+    get:
+      tags:
+      - Deadlines
+      summary: Get Callback Logs
+      description: Get the execution logs of a deadline callback.
+      operationId: get_callback_logs
+      security:
+      - OAuth2PasswordBearer: []
+      - HTTPBearer: []
+      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: callback_id
+        in: path
+        required: true
+        schema:
+          type: string
+          format: uuid
+          title: Callback Id
+      - name: accept
+        in: header
+        required: false
+        schema:
+          type: string
+          enum:
+          - application/json
+          - application/x-ndjson
+          - '*/*'
+          default: '*/*'
+          title: Accept
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/TaskInstancesLogResponse'
+            application/x-ndjson:
+              schema:
+                type: string
+                example: '{"content": "content"}
+
+                  {"content": "content"}
+
+                  '
+        '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'
   /ui/structure/structure_data:
     get:
       tags:
@@ -3274,6 +3350,17 @@ components:
       - count
       title: CalendarTimeRangeResponse
       description: Represents a summary of DAG runs for a specific calendar 
time range.
+    CallbackState:
+      type: string
+      enum:
+      - scheduled
+      - pending
+      - queued
+      - running
+      - success
+      - failed
+      title: CallbackState
+      description: All possible states of callbacks.
     ConfigResponse:
       properties:
         fallback_page_limit:
@@ -4102,6 +4189,22 @@ components:
           - type: string
           - type: 'null'
           title: Team Name
+        callback_id:
+          type: string
+          format: uuid
+          title: Callback Id
+        callback_type:
+          type: string
+          title: Callback Type
+        callback_state:
+          anyOf:
+          - $ref: '#/components/schemas/CallbackState'
+          - type: 'null'
+        callback_path:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Callback Path
       type: object
       required:
       - id
@@ -4110,6 +4213,8 @@ components:
       - created_at
       - dag_id
       - dag_run_id
+      - callback_id
+      - callback_type
       title: DeadlineResponse
       description: Deadline serializer for responses.
     DurationStats:
@@ -5598,6 +5703,21 @@ components:
       - nodes
       title: StructureDataResponse
       description: Structure Data serializer for responses.
+    StructuredLogMessage:
+      properties:
+        timestamp:
+          type: string
+          format: date-time
+          title: Timestamp
+        event:
+          type: string
+          title: Event
+      additionalProperties: true
+      type: object
+      required:
+      - event
+      title: StructuredLogMessage
+      description: An individual log message.
     TaskInletAssetReference:
       properties:
         dag_id:
@@ -5920,6 +6040,28 @@ components:
       - awaiting_input
       title: TaskInstanceStateCount
       description: TaskInstance serializer for responses.
+    TaskInstancesLogResponse:
+      properties:
+        content:
+          anyOf:
+          - items:
+              $ref: '#/components/schemas/StructuredLogMessage'
+            type: array
+          - items:
+              type: string
+            type: array
+          title: Content
+        continuation_token:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Continuation Token
+      type: object
+      required:
+      - content
+      - continuation_token
+      title: TaskInstancesLogResponse
+      description: Log serializer for responses.
     TaskOutletAssetReference:
       properties:
         dag_id:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
index 6a239540a75..c6298e2062e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
@@ -18,14 +18,17 @@
 from __future__ import annotations
 
 from typing import Annotated
+from uuid import UUID
 
 from fastapi import Depends, HTTPException, status
+from fastapi.responses import StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.orm import contains_eager, noload
 
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
 from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
 from airflow.api_fastapi.common.db.dags import eager_load_teams
+from airflow.api_fastapi.common.headers import HeaderAcceptJsonOrNdjson
 from airflow.api_fastapi.common.parameters import (
     FilterParam,
     QueryLimit,
@@ -38,17 +41,25 @@ from airflow.api_fastapi.common.parameters import (
     teams_filter_factory,
 )
 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.ui.deadline import (
     DeadlineAlertCollectionResponse,
     DeadlineCollectionResponse,
 )
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.routes.public.log import (
+    _buffered_ndjson_stream,
+    ndjson_example_response_for_get_log,
+)
 from airflow.api_fastapi.core_api.security import ReadableDagRunsFilterDep, 
requires_access_dag
+from airflow.models.callback import Callback, CallbackType
 from airflow.models.dag_version import DagVersion
 from airflow.models.dagrun import DagRun
 from airflow.models.deadline import Deadline
 from airflow.models.deadline_alert import DeadlineAlert
 from airflow.models.serialized_dag import SerializedDagModel
+from airflow.utils.log.path_log_reader import read_logs_at_path, 
validate_log_path_component
 
 deadlines_router = AirflowRouter(prefix="/dags/{dag_id}", tags=["Deadlines"])
 
@@ -87,6 +98,8 @@ def get_deadlines(
                     "dag_id": DagRun.dag_id,
                     "dag_run_id": DagRun.run_id,
                     "alert_name": DeadlineAlert.name,
+                    "callback_state": Callback.state,
+                    "callback_type": Callback.type,
                 },
             ).dynamic_depends(default="deadline_time")
         ),
@@ -107,13 +120,14 @@ def get_deadlines(
     query = (
         select(Deadline)
         .join(Deadline.dagrun)
+        .join(Deadline.callback)
         .outerjoin(Deadline.deadline_alert)
         .options(
             contains_eager(Deadline.dagrun).options(
                 noload(DagRun.deadlines), *eager_load_teams(DagRun.dag_model)
             ),
             contains_eager(Deadline.deadline_alert),
-            noload(Deadline.callback),
+            contains_eager(Deadline.callback),
         )
     )
 
@@ -217,3 +231,62 @@ def get_dag_deadline_alerts(
     alerts = session.scalars(alerts_select)
 
     return DeadlineAlertCollectionResponse(deadline_alerts=alerts, 
total_entries=total_entries)
+
+
+@deadlines_router.get(
+    "/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs",
+    responses={
+        **create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, 
status.HTTP_404_NOT_FOUND]),
+        status.HTTP_200_OK: {
+            "description": "Successful Response",
+            "content": ndjson_example_response_for_get_log,
+        },
+    },
+    dependencies=[Depends(requires_access_dag(method="GET", 
access_entity=DagAccessEntity.TASK_LOGS))],
+    response_model=TaskInstancesLogResponse,
+    response_model_exclude_unset=True,
+)
+def get_callback_logs(
+    dag_id: str,
+    dag_run_id: str,
+    callback_id: UUID,
+    accept: HeaderAcceptJsonOrNdjson,
+    session: SessionDep,
+):
+    """Get the execution logs of a deadline callback."""
+    # Both are used as log path components, so reject anything that could 
escape the log folder.
+    for param_name, param_value in (("dag_id", dag_id), ("dag_run_id", 
dag_run_id)):
+        try:
+            validate_log_path_component(param_value)
+        except ValueError:
+            raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Invalid 
characters in {param_name}")
+
+    callback_type = session.scalar(
+        select(Callback.type)
+        .join(Deadline, Deadline.callback_id == Callback.id)
+        .join(Deadline.dagrun)
+        .where(Deadline.callback_id == callback_id, DagRun.dag_id == dag_id, 
DagRun.run_id == dag_run_id)
+        .limit(1)
+    )
+    if callback_type is None:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND,
+            f"Callback `{callback_id}` with a deadline for DagRun 
`{dag_run_id}` of Dag `{dag_id}` was not found",
+        )
+
+    # Executor callbacks log to ``executor_callbacks/...`` (see 
``ExecuteCallback.make()``) and
+    # triggerer callbacks to ``triggerer_callbacks/...`` (see 
``TriggerLoggingFactory``). Read only the
+    # path for this callback's type: CloudWatch reports a missing log stream 
as a log line, not as no logs.
+    callback_path = f"{dag_id}/{dag_run_id}/{callback_id}"
+    if callback_type == CallbackType.TRIGGERER:
+        log_stream = 
read_logs_at_path(f"triggerer_callbacks/{callback_path}/callback.log")
+    else:
+        log_stream = read_logs_at_path(f"executor_callbacks/{callback_path}")
+
+    if accept == Mimetype.NDJSON:
+        return StreamingResponse(
+            media_type="application/x-ndjson",
+            content=_buffered_ndjson_stream(f"{log.model_dump_json()}\n" for 
log in log_stream),
+        )
+
+    return TaskInstancesLogResponse.model_construct(content=list(log_stream), 
continuation_token=None)
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 24a1432a2d8..b29cf66a42e 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -454,7 +454,8 @@ The types of messages that the async Trigger Runner can 
send back up to the supe
 class TriggerLoggingFactory:
     log_path: str
 
-    ti: RuntimeTI = attrs.field(repr=False)
+    # Callback triggers have no task instance; ``upload_to_remote`` accepts 
``ti=None``.
+    ti: RuntimeTI | None = attrs.field(default=None, repr=False)
 
     bound_logger: WrappedLogger = attrs.field(init=False, repr=False)
 
@@ -891,6 +892,19 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
             if trigger.assets:
                 watched_assets = {a.name: a.uri for a in trigger.assets}
 
+            if callback := getattr(trigger, "callback", None):
+                # Log callback triggers to a file the UI callback logs 
endpoint can read.
+                callback_data = callback.data or {}
+                context = (callback_data.get("kwargs") or {}).get("context") 
or {}
+                dag_run_data = context.get("dag_run") or {}
+                dag_id = callback_data.get("dag_id") or 
dag_run_data.get("dag_id") or "unknown"
+                run_id = dag_run_data.get("dag_run_id") or "unknown"
+                # One directory per callback: with delete_local_logs, remote 
handlers delete the
+                # log file's parent directory after upload, which must not 
hold other callbacks' logs.
+                self.logger_cache[trigger.id] = TriggerLoggingFactory(
+                    
log_path=f"triggerer_callbacks/{dag_id}/{run_id}/{callback.id}/callback.log",
+                )
+
             return workloads.RunTrigger(
                 id=trigger.id,
                 classpath=trigger.classpath,
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 02ed3b0f2cd..22ca2c78ed2 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1160,6 +1160,15 @@ export const 
UseDeadlinesServiceGetDagDeadlineAlertsKeyFn = ({ dagId, limit, off
   orderBy?: string[];
   versionNumber?: number;
 }, queryKey?: Array<unknown>) => [useDeadlinesServiceGetDagDeadlineAlertsKey, 
...(queryKey ?? [{ dagId, limit, offset, orderBy, versionNumber }])];
+export type DeadlinesServiceGetCallbackLogsDefaultResponse = 
Awaited<ReturnType<typeof DeadlinesService.getCallbackLogs>>;
+export type DeadlinesServiceGetCallbackLogsQueryResult<TData = 
DeadlinesServiceGetCallbackLogsDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
+export const useDeadlinesServiceGetCallbackLogsKey = 
"DeadlinesServiceGetCallbackLogs";
+export const UseDeadlinesServiceGetCallbackLogsKeyFn = ({ accept, callbackId, 
dagId, dagRunId }: {
+  accept?: "application/json" | "*/*" | "application/x-ndjson";
+  callbackId: string;
+  dagId: string;
+  dagRunId: string;
+}, queryKey?: Array<unknown>) => [useDeadlinesServiceGetCallbackLogsKey, 
...(queryKey ?? [{ accept, callbackId, dagId, dagRunId }])];
 export type StructureServiceStructureDataDefaultResponse = 
Awaited<ReturnType<typeof StructureService.structureData>>;
 export type StructureServiceStructureDataQueryResult<TData = 
StructureServiceStructureDataDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
 export const useStructureServiceStructureDataKey = 
"StructureServiceStructureData";
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 7624712dac2..426338f18ec 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -2247,7 +2247,7 @@ export const ensureUseDashboardServiceDagStatsData = 
(queryClient: QueryClient)
 * @param data.dagRunId
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state, callback_type`
 * @param data.missed
 * @param data.deadlineTimeGte
 * @param data.deadlineTimeGt
@@ -2298,6 +2298,23 @@ export const 
ensureUseDeadlinesServiceGetDagDeadlineAlertsData = (queryClient: Q
   versionNumber?: number;
 }) => queryClient.ensureQueryData({ queryKey: 
Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, 
orderBy, versionNumber }), queryFn: () => 
DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy, 
versionNumber }) });
 /**
+* Get Callback Logs
+* Get the execution logs of a deadline callback.
+* @param data The data for the request.
+* @param data.dagId
+* @param data.dagRunId
+* @param data.callbackId
+* @param data.accept
+* @returns TaskInstancesLogResponse Successful Response
+* @throws ApiError
+*/
+export const ensureUseDeadlinesServiceGetCallbackLogsData = (queryClient: 
QueryClient, { accept, callbackId, dagId, dagRunId }: {
+  accept?: "application/json" | "*/*" | "application/x-ndjson";
+  callbackId: string;
+  dagId: string;
+  dagRunId: string;
+}) => queryClient.ensureQueryData({ queryKey: 
Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ accept, callbackId, dagId, 
dagRunId }), queryFn: () => DeadlinesService.getCallbackLogs({ accept, 
callbackId, dagId, dagRunId }) });
+/**
 * Structure Data
 * Get Structure Data.
 * @param data The data for the request.
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 a4741bc8602..2a3c519a9c8 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -2247,7 +2247,7 @@ export const prefetchUseDashboardServiceDagStats = 
(queryClient: QueryClient) =>
 * @param data.dagRunId
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state, callback_type`
 * @param data.missed
 * @param data.deadlineTimeGte
 * @param data.deadlineTimeGt
@@ -2298,6 +2298,23 @@ export const 
prefetchUseDeadlinesServiceGetDagDeadlineAlerts = (queryClient: Que
   versionNumber?: number;
 }) => queryClient.prefetchQuery({ queryKey: 
Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, 
orderBy, versionNumber }), queryFn: () => 
DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy, 
versionNumber }) });
 /**
+* Get Callback Logs
+* Get the execution logs of a deadline callback.
+* @param data The data for the request.
+* @param data.dagId
+* @param data.dagRunId
+* @param data.callbackId
+* @param data.accept
+* @returns TaskInstancesLogResponse Successful Response
+* @throws ApiError
+*/
+export const prefetchUseDeadlinesServiceGetCallbackLogs = (queryClient: 
QueryClient, { accept, callbackId, dagId, dagRunId }: {
+  accept?: "application/json" | "*/*" | "application/x-ndjson";
+  callbackId: string;
+  dagId: string;
+  dagRunId: string;
+}) => queryClient.prefetchQuery({ queryKey: 
Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ accept, callbackId, dagId, 
dagRunId }), queryFn: () => DeadlinesService.getCallbackLogs({ accept, 
callbackId, dagId, dagRunId }) });
+/**
 * Structure Data
 * Get Structure Data.
 * @param data The data for the request.
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 d3039078814..03a2a716e0f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2247,7 +2247,7 @@ export const useDashboardServiceDagStats = <TData = 
Common.DashboardServiceDagSt
 * @param data.dagRunId
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state, callback_type`
 * @param data.missed
 * @param data.deadlineTimeGte
 * @param data.deadlineTimeGt
@@ -2298,6 +2298,23 @@ export const useDeadlinesServiceGetDagDeadlineAlerts = 
<TData = Common.Deadlines
   versionNumber?: number;
 }, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, 
orderBy, versionNumber }, queryKey), queryFn: () => 
DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy, 
versionNumber }) as TData, ...options });
 /**
+* Get Callback Logs
+* Get the execution logs of a deadline callback.
+* @param data The data for the request.
+* @param data.dagId
+* @param data.dagRunId
+* @param data.callbackId
+* @param data.accept
+* @returns TaskInstancesLogResponse Successful Response
+* @throws ApiError
+*/
+export const useDeadlinesServiceGetCallbackLogs = <TData = 
Common.DeadlinesServiceGetCallbackLogsDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>({ accept, callbackId, dagId, 
dagRunId }: {
+  accept?: "application/json" | "*/*" | "application/x-ndjson";
+  callbackId: string;
+  dagId: string;
+  dagRunId: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ accept, callbackId, dagId, 
dagRunId }, queryKey), queryFn: () => DeadlinesService.getCallbackLogs({ 
accept, callbackId, dagId, dagRunId }) as TData, ...options });
+/**
 * Structure Data
 * Get Structure Data.
 * @param data The data for the request.
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 d9e4d58598b..299870edff3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2247,7 +2247,7 @@ export const useDashboardServiceDagStatsSuspense = <TData 
= Common.DashboardServ
 * @param data.dagRunId
 * @param data.limit
 * @param data.offset
-* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`
+* @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state, callback_type`
 * @param data.missed
 * @param data.deadlineTimeGte
 * @param data.deadlineTimeGt
@@ -2298,6 +2298,23 @@ export const 
useDeadlinesServiceGetDagDeadlineAlertsSuspense = <TData = Common.D
   versionNumber?: number;
 }, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, 
orderBy, versionNumber }, queryKey), queryFn: () => 
DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy, 
versionNumber }) as TData, ...options });
 /**
+* Get Callback Logs
+* Get the execution logs of a deadline callback.
+* @param data The data for the request.
+* @param data.dagId
+* @param data.dagRunId
+* @param data.callbackId
+* @param data.accept
+* @returns TaskInstancesLogResponse Successful Response
+* @throws ApiError
+*/
+export const useDeadlinesServiceGetCallbackLogsSuspense = <TData = 
Common.DeadlinesServiceGetCallbackLogsDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>({ accept, callbackId, dagId, 
dagRunId }: {
+  accept?: "application/json" | "*/*" | "application/x-ndjson";
+  callbackId: string;
+  dagId: string;
+  dagRunId: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ accept, callbackId, dagId, 
dagRunId }, queryKey), queryFn: () => DeadlinesService.getCallbackLogs({ 
accept, callbackId, dagId, dagRunId }) as TData, ...options });
+/**
 * Structure Data
 * Get Structure Data.
 * @param data The data for the request.
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 b02c1d69136..b1fb2e97730 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
@@ -10308,6 +10308,13 @@ export const $CalendarTimeRangeResponse = {
     description: 'Represents a summary of DAG runs for a specific calendar 
time range.'
 } as const;
 
+export const $CallbackState = {
+    type: 'string',
+    enum: ['scheduled', 'pending', 'queued', 'running', 'success', 'failed'],
+    title: 'CallbackState',
+    description: 'All possible states of callbacks.'
+} as const;
+
 export const $ConfigResponse = {
     properties: {
         fallback_page_limit: {
@@ -11290,10 +11297,40 @@ export const $DeadlineResponse = {
                 }
             ],
             title: 'Team Name'
+        },
+        callback_id: {
+            type: 'string',
+            format: 'uuid',
+            title: 'Callback Id'
+        },
+        callback_type: {
+            type: 'string',
+            title: 'Callback Type'
+        },
+        callback_state: {
+            anyOf: [
+                {
+                    '$ref': '#/components/schemas/CallbackState'
+                },
+                {
+                    type: 'null'
+                }
+            ]
+        },
+        callback_path: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Callback Path'
         }
     },
     type: 'object',
-    required: ['id', 'deadline_time', 'missed', 'created_at', 'dag_id', 
'dag_run_id'],
+    required: ['id', 'deadline_time', 'missed', 'created_at', 'dag_id', 
'dag_run_id', 'callback_id', 'callback_type'],
     title: 'DeadlineResponse',
     description: 'Deadline serializer for responses.'
 } as const;
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 b9d60978b19..2aedd73b91e 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
@@ -3,7 +3,7 @@
 import type { CancelablePromise } from './core/CancelablePromise';
 import { OpenAPI } from './core/OpenAPI';
 import { request as __request } from './core/request';
-import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
+import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
 
 export class AssetService {
     /**
@@ -5486,7 +5486,7 @@ export class DeadlinesService {
      * @param data.dagRunId
      * @param data.limit
      * @param data.offset
-     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name`
+     * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `id, 
deadline_time, created_at, last_updated_at, missed, dag_id, dag_run_id, 
alert_name, callback_state, callback_type`
      * @param data.missed
      * @param data.deadlineTimeGte
      * @param data.deadlineTimeGt
@@ -5563,6 +5563,37 @@ export class DeadlinesService {
         });
     }
     
+    /**
+     * Get Callback Logs
+     * Get the execution logs of a deadline callback.
+     * @param data The data for the request.
+     * @param data.dagId
+     * @param data.dagRunId
+     * @param data.callbackId
+     * @param data.accept
+     * @returns TaskInstancesLogResponse Successful Response
+     * @throws ApiError
+     */
+    public static getCallbackLogs(data: GetCallbackLogsData): 
CancelablePromise<GetCallbackLogsResponse> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: 
'/ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs',
+            path: {
+                dag_id: data.dagId,
+                dag_run_id: data.dagRunId,
+                callback_id: data.callbackId
+            },
+            headers: {
+                accept: data.accept
+            },
+            errors: {
+                400: 'Bad Request',
+                404: 'Not Found',
+                422: 'Validation Error'
+            }
+        });
+    }
+    
 }
 
 export class StructureService {
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 21b8d6eea05..d55498a3c66 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
@@ -2681,6 +2681,11 @@ export type CalendarTimeRangeResponse = {
 
 export type state = 'queued' | 'running' | 'success' | 'failed' | 'planned';
 
+/**
+ * All possible states of callbacks.
+ */
+export type CallbackState = 'scheduled' | 'pending' | 'queued' | 'running' | 
'success' | 'failed';
+
 /**
  * configuration serializer.
  */
@@ -2942,6 +2947,10 @@ export type DeadlineResponse = {
     alert_id?: string | null;
     alert_name?: string | null;
     team_name?: string | null;
+    callback_id: string;
+    callback_type: string;
+    callback_state?: CallbackState | null;
+    callback_path?: string | null;
 };
 
 /**
@@ -5462,7 +5471,7 @@ export type GetDeadlinesData = {
     missed?: boolean | null;
     offset?: number;
     /**
-     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, deadline_time, created_at, 
last_updated_at, missed, dag_id, dag_run_id, alert_name`
+     * Attributes to order by, multi criteria sort is supported. Prefix with 
`-` for descending order. Supported attributes: `id, deadline_time, created_at, 
last_updated_at, missed, dag_id, dag_run_id, alert_name, callback_state, 
callback_type`
      */
     orderBy?: Array<(string)>;
     teams?: Array<(string)>;
@@ -5483,6 +5492,15 @@ export type GetDagDeadlineAlertsData = {
 
 export type GetDagDeadlineAlertsResponse = DeadlineAlertCollectionResponse;
 
+export type GetCallbackLogsData = {
+    accept?: 'application/json' | 'application/x-ndjson' | '*/*';
+    callbackId: string;
+    dagId: string;
+    dagRunId: string;
+};
+
+export type GetCallbackLogsResponse = TaskInstancesLogResponse;
+
 export type StructureDataData = {
     dagId: string;
     depth?: number | null;
@@ -9851,6 +9869,29 @@ export type $OpenApiTs = {
             };
         };
     };
+    '/ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs': {
+        get: {
+            req: GetCallbackLogsData;
+            res: {
+                /**
+                 * Successful Response
+                 */
+                200: TaskInstancesLogResponse;
+                /**
+                 * Bad Request
+                 */
+                400: HTTPExceptionResponse;
+                /**
+                 * Not Found
+                 */
+                404: HTTPExceptionResponse;
+                /**
+                 * Validation Error
+                 */
+                422: HTTPValidationError;
+            };
+        };
+    };
     '/ui/structure/structure_data': {
         get: {
             req: StructureDataData;
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index e1e03529b32..6d1cdb99622 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -414,6 +414,7 @@
     "no_status": "No Status",
     "none": "No Status",
     "open": "Open",
+    "pending": "Pending",
     "planned": "Planned",
     "queued": "Queued",
     "removed": "Removed",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index db2aec35b56..b098205824e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -36,6 +36,21 @@
       "wednesday": "Wed"
     }
   },
+  "callbacks": {
+    "callback_one": "Callback",
+    "callback_other": "Callbacks",
+    "columns": {
+      "alertName": "Deadline Alert",
+      "callback": "Callback",
+      "deadlineTime": "Deadline Time",
+      "missedBy": "Missed by",
+      "type": "Type"
+    },
+    "types": {
+      "executor": "Sync",
+      "triggerer": "Async"
+    }
+  },
   "code": {
     "bundleUrl": "Bundle Url",
     "noCode": "No Code Found",
@@ -244,6 +259,7 @@
     "auditLog": "Audit Log",
     "backfills": "Backfills",
     "calendar": "Calendar",
+    "callbacks": "Callbacks",
     "code": "Code",
     "details": "Details",
     "logs": "Logs",
diff --git 
a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.expanding.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.expanding.test.tsx
new file mode 100644
index 00000000000..7140a0bc3f2
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.expanding.test.tsx
@@ -0,0 +1,74 @@
+/*!
+ * 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 { Text } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import "@testing-library/jest-dom/vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ChakraWrapper } from "src/utils/ChakraWrapper.tsx";
+
+import { DataTable } from "./DataTable.tsx";
+import type { DataTableFeatures } from "./features.ts";
+
+const columns: Array<ColumnDef<DataTableFeatures, { name: string }>> = [
+  {
+    accessorKey: "name",
+    cell: (info) => info.getValue(),
+    header: "Name",
+  },
+  {
+    cell: ({ row }) => (
+      <button onClick={row.getToggleExpandedHandler()} type="button">
+        {`expand ${row.original.name}`}
+      </button>
+    ),
+    header: "Expand",
+    id: "expand",
+  },
+];
+
+const data = [{ name: "John Doe" }, { name: "Jane Doe" }];
+
+const table = (rows: Array<{ name: string }>) => (
+  <DataTable
+    columns={columns}
+    data={rows}
+    getRowId={(row) => row.name}
+    modelName="task"
+    renderSubComponent={(row) => <Text>{`details of ${row.name}`}</Text>}
+  />
+);
+
+describe("DataTable row expanding", () => {
+  it("keeps an expanded row open, under the same record, when the data 
refetches in another order", async () => {
+    const { rerender } = render(table(data), { wrapper: ChakraWrapper });
+
+    expect(screen.queryByTestId("table-sub-row")).not.toBeInTheDocument();
+    fireEvent.click(screen.getByRole("button", { name: "expand Jane Doe" }));
+    expect(screen.getByTestId("table-sub-row")).toHaveTextContent("details of 
Jane Doe");
+
+    rerender(table([...data].reverse()));
+    // TanStack schedules its auto-reset of expanded rows in a microtask after 
the new data is read.
+    await act(() => Promise.resolve());
+
+    expect(screen.getAllByTestId("table-sub-row")).toHaveLength(1);
+    expect(screen.getByTestId("table-sub-row")).toHaveTextContent("details of 
Jane Doe");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx 
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
index e7783f11a3f..67e59fc0b4d 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -60,6 +60,8 @@ type DataTableProps<TData extends RowData> = {
    * Anything that changes how already-returned rows are drawn belongs in 
`presentationActions`.
    */
   readonly filterActions?: ReactNode;
+  /** Stable row ids, so an expanded row stays open across refetches and 
re-sorts. Defaults to the row index. */
+  readonly getRowId?: (row: TData) => string;
   /** Rendered next to the row count heading, and on its own when the heading 
is hidden. */
   readonly headingExtra?: ReactNode;
   readonly hideRowCountHeading?: boolean;
@@ -100,6 +102,11 @@ type DataTableProps<TData extends RowData> = {
    * actions over a selection belong in the `ActionBar` below the table.
    */
   readonly primaryActions?: ReactNode;
+  /**
+   * Content shown in a full-width row under each expanded row. Rows open and 
close through
+   * `row.getToggleExpandedHandler()` in a cell. See 
https://tanstack.com/table/latest/docs/framework/react/examples/sub-components
+   */
+  readonly renderSubComponent?: (row: TData) => ReactNode;
   /** Defaults to showing the column visibility menu only when there are many 
columns. */
   readonly showColumnsMenu?: boolean;
   readonly showDisplayToggle?: boolean;
@@ -131,6 +138,7 @@ export const DataTable = <TData extends RowData>({
   enableMultiSort = false,
   errorMessage,
   filterActions,
+  getRowId,
   headingExtra,
   hideRowCountHeading,
   initialState,
@@ -143,6 +151,7 @@ export const DataTable = <TData extends RowData>({
   presentationActions,
   previousCursor,
   primaryActions,
+  renderSubComponent,
   showColumnsMenu,
   showDisplayToggle,
   skeletonCount = 10,
@@ -216,11 +225,15 @@ export const DataTable = <TData extends RowData>({
   const rest = Boolean(isLoading) ? createSkeletonMock(display, skeletonCount, 
columns) : {};
 
   const table = useTable({
+    // Refetches replace `data`, which would otherwise close every expanded 
row.
+    autoResetExpanded: false,
     columns,
     data,
     enableHiding: true,
     enableMultiSort,
     features: dataTableFeatures,
+    getRowCanExpand: () => renderSubComponent !== undefined,
+    getRowId,
     manualPagination: true,
     manualSorting: true,
     onColumnVisibilityChange: setColumnVisibility,
@@ -329,7 +342,7 @@ export const DataTable = <TData extends RowData>({
         {display === "card" && cardDef !== undefined ? (
           <CardList cardDef={cardDef} isLoading={isLoading} 
noRowsMessage={noRowsNode} rows={rows} />
         ) : (
-          <TableList noRowsMessage={noRowsNode} table={table} />
+          <TableList noRowsMessage={noRowsNode} 
renderSubComponent={renderSubComponent} table={table} />
         )}
       </Box>
       {hasOffsetPagination ? (
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx 
b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
index f6bda4bd402..9e1a9229e6e 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { ReactNode } from "react";
+import { Fragment, type ReactNode } from "react";
 
 import { Button, Icon, Table, Text } from "@chakra-ui/react";
 import { flexRender, type RowData, type Table as TanStackTable } from 
"@tanstack/react-table";
@@ -29,10 +29,15 @@ import type { DataTableFeatures } from "./features";
 
 type TableListProps<TData extends RowData> = {
   readonly noRowsMessage?: ReactNode;
+  readonly renderSubComponent?: (row: TData) => ReactNode;
   readonly table: TanStackTable<DataTableFeatures, TData>;
 };
 
-export const TableList = <TData extends RowData>({ noRowsMessage, table }: 
TableListProps<TData>) => {
+export const TableList = <TData extends RowData>({
+  noRowsMessage,
+  renderSubComponent,
+  table,
+}: TableListProps<TData>) => {
   "use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is 
resolved
   const { t: translate } = useTranslation("components");
   const { rows } = table.getRowModel();
@@ -115,13 +120,22 @@ export const TableList = <TData extends RowData>({ 
noRowsMessage, table }: Table
           </Table.Row>
         ) : (
           rows.map((row) => (
-            <Table.Row key={row.id}>
-              {row.getVisibleCells().map((cell) => (
-                <Table.Cell data-testid={`table-cell-${cell.column.id}`} 
key={cell.id}>
-                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
-                </Table.Cell>
-              ))}
-            </Table.Row>
+            <Fragment key={row.id}>
+              <Table.Row>
+                {row.getVisibleCells().map((cell) => (
+                  <Table.Cell data-testid={`table-cell-${cell.column.id}`} 
key={cell.id}>
+                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                  </Table.Cell>
+                ))}
+              </Table.Row>
+              {renderSubComponent !== undefined && row.getIsExpanded() ? (
+                <Table.Row data-testid="table-sub-row">
+                  <Table.Cell colSpan={row.getVisibleCells().length}>
+                    {renderSubComponent(row.original)}
+                  </Table.Cell>
+                </Table.Row>
+              ) : undefined}
+            </Fragment>
           ))
         )}
       </Table.Body>
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/features.ts 
b/airflow-core/src/airflow/ui/src/components/DataTable/features.ts
index b8ba223f340..690effeea53 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/features.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/features.ts
@@ -18,15 +18,18 @@
  */
 import {
   columnVisibilityFeature,
+  rowExpandingFeature,
   rowPaginationFeature,
   rowSortingFeature,
   tableFeatures,
 } from "@tanstack/react-table";
 
 // Pagination and sorting are server-side 
(`manualPagination`/`manualSorting`), so no row models
-// are registered: rows render in the order and slice the API returns them.
+// are registered: rows render in the order and slice the API returns them. 
Expanding only tracks
+// which rows are open, for tables that pass `renderSubComponent`.
 export const dataTableFeatures = tableFeatures({
   columnVisibilityFeature,
+  rowExpandingFeature,
   rowPaginationFeature,
   rowSortingFeature,
 });
diff --git 
a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx 
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
index 98ee85cdd15..321b9476394 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
@@ -22,7 +22,12 @@ import { describe, it, expect } from "vitest";
 
 import { Wrapper } from "src/utils/Wrapper";
 
-import { renderStructuredLog, renderTIContextPreamble, tiContextFields } from 
"./renderStructuredLog";
+import {
+  extractTIContext,
+  renderStructuredLog,
+  renderTIContextPreamble,
+  tiContextFields,
+} from "./renderStructuredLog";
 
 const translate = (key: string) => key;
 
@@ -160,6 +165,17 @@ describe("renderStructuredLog — TI context field 
stripping", () => {
   });
 });
 
+describe("extractTIContext", () => {
+  it("ignores TI fields logged as null, e.g. by callback triggers", () => {
+    const nullContext = { dag_id: null, event: "trigger starting", task_id: 
null, ti_id: null };
+
+    expect(extractTIContext([nullContext as never])).toBeUndefined();
+    expect(extractTIContext([nullContext as never, { dag_id: "my_dag", event: 
"x" }])).toEqual({
+      dag_id: "my_dag",
+    });
+  });
+});
+
 describe("renderTIContextPreamble", () => {
   it("text mode: returns key=value pairs joined by spaces, prefixed with 
label", () => {
     const result = renderTIContextPreamble(
diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx 
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index 37fdb5d1c15..8744cccee62 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -154,7 +154,8 @@ const extractFromStructuredDatum = (
   const ctx: Record<string, unknown> = {};
 
   for (const field of tiContextFields) {
-    if (Object.hasOwn(line, field) && line[field] !== undefined) {
+    // Callback triggers log these fields as null since they have no task 
instance.
+    if (Object.hasOwn(line, field) && line[field] !== undefined && line[field] 
!== null) {
       ctx[field] = line[field];
     }
   }
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.test.tsx
new file mode 100644
index 00000000000..ef1c44603c0
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.test.tsx
@@ -0,0 +1,85 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+
+import { TimezoneProvider } from "src/context/timezone";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { CallbackLogs } from "./CallbackLogs";
+
+vi.mock("openapi/queries", async (importOriginal) => {
+  const actual = await importOriginal<typeof OpenapiQueries>();
+
+  return { ...actual, useDeadlinesServiceGetCallbackLogs: vi.fn() };
+});
+
+vi.mock("src/queries/useConfig", () => ({ useConfig: () => undefined }));
+
+const { useDeadlinesServiceGetCallbackLogs } = await import("openapi/queries");
+
+const CALLBACK_ID = "0199a8e0-0000-7000-8000-000000000001";
+
+beforeAll(() => {
+  Object.defineProperty(HTMLElement.prototype, "offsetHeight", { value: 20 });
+  Object.defineProperty(HTMLElement.prototype, "offsetWidth", { value: 800 });
+});
+
+describe("CallbackLogs", () => {
+  it("renders the callback's logs with the task log viewer", async () => {
+    vi.mocked(useDeadlinesServiceGetCallbackLogs).mockReturnValue({
+      data: {
+        content: [
+          { event: "Deadline miss callback started", level: "info", timestamp: 
"2026-10-01T00:00:06Z" },
+          { event: "Deadline miss callback finished", level: "info", 
timestamp: "2026-10-01T00:00:07Z" },
+        ],
+      },
+      error: null,
+      isLoading: false,
+    } as unknown as ReturnType<typeof useDeadlinesServiceGetCallbackLogs>);
+
+    render(
+      <BaseWrapper>
+        <MemoryRouter initialEntries={["/dags/my_dag/runs/run_1/callbacks"]}>
+          <TimezoneProvider>
+            <Routes>
+              <Route
+                element={<CallbackLogs callbackId={CALLBACK_ID} />}
+                path="/dags/:dagId/runs/:runId/callbacks"
+              />
+            </Routes>
+          </TimezoneProvider>
+        </MemoryRouter>
+      </BaseWrapper>,
+    );
+
+    
expect(vi.mocked(useDeadlinesServiceGetCallbackLogs).mock.lastCall?.[0]).toEqual({
+      accept: "application/x-ndjson",
+      callbackId: CALLBACK_ID,
+      dagId: "my_dag",
+      dagRunId: "run_1",
+    });
+    await waitFor(() => expect(screen.getByText(/Deadline miss callback 
started/u)).toBeInTheDocument());
+    expect(screen.getByText(/Deadline miss callback 
finished/u)).toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.tsx
new file mode 100644
index 00000000000..55df7d2dfa1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogs.tsx
@@ -0,0 +1,189 @@
+/*!
+ * 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 { useState } from "react";
+
+import { Box, Heading } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { useParams, useSearchParams } from "react-router-dom";
+import { useLocalStorage } from "usehooks-ts";
+
+import { Modal } from "src/system-components";
+
+import { TaskLogContent, type TaskLogContentProps } from 
"src/pages/TaskInstance/Logs/TaskLogContent";
+import { TaskLogHeader, type TaskLogHeaderProps } from 
"src/pages/TaskInstance/Logs/TaskLogHeader";
+import { getDownloadText } from "src/pages/TaskInstance/Logs/utils";
+
+import {
+  LOG_SHOW_LOG_LEVEL_KEY,
+  LOG_SHOW_SOURCE_KEY,
+  LOG_SHOW_TIMESTAMP_KEY,
+  LOG_WRAP_KEY,
+} from "src/constants/localStorage";
+import { SearchParamsKeys } from "src/constants/searchParams";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
+import { useConfig } from "src/queries/useConfig";
+import { useCallbackLogs } from "src/queries/useLogs";
+
+// Shown under a callback's row in the Callbacks tab.
+export const CallbackLogs = ({ callbackId }: { readonly callbackId: string }) 
=> {
+  const { dagId = "", runId = "" } = useParams();
+  const [searchParams] = useSearchParams();
+  const { t: translate } = useTranslation("dag");
+
+  const logLevelFilters = searchParams.getAll(SearchParamsKeys.LOG_LEVEL);
+  const sourceFilters = searchParams.getAll(SearchParamsKeys.SOURCE);
+
+  const defaultWrap = Boolean(useConfig("default_wrap"));
+
+  const [wrap, setWrap] = useLocalStorage<boolean>(LOG_WRAP_KEY, defaultWrap);
+  const [showTimestamp, setShowTimestamp] = 
useLocalStorage<boolean>(LOG_SHOW_TIMESTAMP_KEY, true);
+  const [showSource, setShowSource] = 
useLocalStorage<boolean>(LOG_SHOW_SOURCE_KEY, false);
+  const [showLogLevel, setShowLogLevel] = 
useLocalStorage<boolean>(LOG_SHOW_LOG_LEVEL_KEY, true);
+  const [fullscreen, setFullscreen] = useState(false);
+  const [expanded, setExpanded] = useState(false);
+  const [searchQuery, setSearchQuery] = useState("");
+  const [activeSearchIndex, setActiveSearchIndex] = useState(0);
+
+  const { error, fetchedData, isLoading, parsedData } = useCallbackLogs({
+    callbackId,
+    dagId,
+    dagRunId: runId,
+    logLevelFilters,
+    showLogLevel,
+    showSource,
+    showTimestamp,
+    sourceFilters,
+  });
+
+  const getLogString = () =>
+    getDownloadText({
+      fetchedData,
+      logLevelFilters,
+      showLogLevel,
+      showSource,
+      showTimestamp,
+      sourceFilters,
+      translate,
+    })
+      .filter((line) => line !== "")
+      .join("\n");
+
+  const searchMatchIndices = searchQuery
+    ? parsedData.searchableText.flatMap((line, index) =>
+        line.toLowerCase().includes(searchQuery.toLowerCase()) ? [index] : [],
+      )
+    : [];
+
+  const downloadLogs = () => {
+    const element = document.createElement("a");
+
+    element.href = URL.createObjectURL(new Blob([getLogString()], { type: 
"text/plain" }));
+    element.download = `logs_${dagId}_${runId}_callback_${callbackId}.txt`;
+    document.body.append(element);
+    element.click();
+    element.remove();
+  };
+
+  const toggleWrap = () => setWrap(!wrap);
+  const toggleTimestamp = () => setShowTimestamp(!showTimestamp);
+  const toggleLogLevel = () => setShowLogLevel(!showLogLevel);
+  const toggleSource = () => setShowSource(!showSource);
+  const toggleFullscreen = () => setFullscreen(!fullscreen);
+  const toggleExpanded = () => setExpanded((act) => !act);
+
+  useShortcut({ ...SHORTCUTS.logs.toggleWrap, callback: toggleWrap });
+  useShortcut({ ...SHORTCUTS.logs.toggleFullscreen, callback: toggleFullscreen 
});
+  useShortcut({ ...SHORTCUTS.logs.toggleExpand, callback: toggleExpanded });
+  useShortcut({ ...SHORTCUTS.logs.toggleTimestamp, callback: toggleTimestamp 
});
+  useShortcut({ ...SHORTCUTS.logs.toggleLogLevel, callback: toggleLogLevel });
+  useShortcut({ ...SHORTCUTS.logs.toggleSource, callback: toggleSource });
+  useShortcut({ ...SHORTCUTS.logs.downloadLogs, callback: downloadLogs });
+
+  const logHeaderProps: TaskLogHeaderProps = {
+    downloadLogs,
+    expanded,
+    getLogString,
+    onSelectTryNumber: () => undefined,
+    search: {
+      currentMatchIndex: activeSearchIndex,
+      onSearchChange: (query: string) => {
+        setSearchQuery(query);
+        setActiveSearchIndex(0);
+      },
+      onSearchNext: () => setActiveSearchIndex((prev) => (prev + 1) % 
Math.max(searchMatchIndices.length, 1)),
+      onSearchPrevious: () =>
+        setActiveSearchIndex(
+          (prev) => (prev - 1 + searchMatchIndices.length) % 
Math.max(searchMatchIndices.length, 1),
+        ),
+      searchQuery,
+      totalMatches: searchMatchIndices.length,
+    },
+    showLogLevel,
+    showSource,
+    showTimestamp,
+    sourceOptions: parsedData.sources,
+    toggleExpanded,
+    toggleFullscreen,
+    toggleLogLevel,
+    toggleSource,
+    toggleTimestamp,
+    toggleWrap,
+    wrap,
+  };
+
+  const logContentProps: TaskLogContentProps = {
+    currentMatchLineIndex: searchMatchIndices[activeSearchIndex],
+    error,
+    expanded,
+    isLoading,
+    logError: error,
+    parsedLogs: parsedData.parsedLogs ?? [],
+    searchMatchIndices: searchQuery ? new Set(searchMatchIndices) : undefined,
+    searchQuery: searchQuery || undefined,
+    wrap,
+  };
+
+  return (
+    <Box display="flex" flexDirection="column" maxH="60vh" p={2}>
+      <TaskLogHeader {...logHeaderProps} />
+      <TaskLogContent {...logContentProps} />
+      <Modal
+        bodyProps={{ display: "flex", flexDirection: "column" }}
+        headerProps={{
+          children: (
+            <Box display="flex" flexDirection="column" width="100%">
+              <Heading mb={2} size="xl">
+                {callbackId}
+              </Heading>
+              <TaskLogHeader {...logHeaderProps} isFullscreen />
+            </Box>
+          ),
+          width: "100%",
+        }}
+        onOpenChange={() => setFullscreen(false)}
+        open={fullscreen}
+        scrollBehavior="inside"
+        size="full"
+      >
+        <TaskLogContent {...logContentProps} />
+      </Modal>
+    </Box>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx
new file mode 100644
index 00000000000..21db4f5ccb7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.test.tsx
@@ -0,0 +1,139 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+import type { DeadlineResponse } from "openapi/requests/types.gen";
+
+import { TimezoneProvider } from "src/context/timezone";
+import i18n from "src/i18n/config";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Callbacks } from "./Callbacks";
+
+vi.mock("openapi/queries", async (importOriginal) => {
+  const actual = await importOriginal<typeof OpenapiQueries>();
+
+  return {
+    ...actual,
+    useDeadlinesServiceGetCallbackLogs: vi.fn(),
+    useDeadlinesServiceGetDeadlines: vi.fn(),
+  };
+});
+
+vi.mock("src/queries/useConfig", () => ({ useConfig: () => undefined }));
+
+const { useDeadlinesServiceGetCallbackLogs, useDeadlinesServiceGetDeadlines } =
+  await import("openapi/queries");
+
+beforeAll(() => {
+  Object.defineProperty(HTMLElement.prototype, "offsetHeight", { value: 20 });
+  Object.defineProperty(HTMLElement.prototype, "offsetWidth", { value: 800 });
+});
+
+const deadlines: Array<DeadlineResponse> = [
+  {
+    alert_name: "sla_alert",
+    callback_id: "0199a8e0-0000-7000-8000-000000000001",
+    callback_path: "dags.notify.on_miss",
+    callback_state: "success",
+    callback_type: "executor",
+    created_at: "2026-10-01T00:00:00Z",
+    dag_id: "my_dag",
+    dag_run_id: "run_1",
+    deadline_time: "2026-10-01T00:00:05Z",
+    id: "0199a8e0-0000-7000-8000-00000000000a",
+    missed: true,
+  },
+  {
+    alert_name: null,
+    callback_id: "0199a8e0-0000-7000-8000-000000000002",
+    callback_path: "dags.notify.async_on_miss",
+    callback_state: "pending",
+    callback_type: "triggerer",
+    created_at: "2026-10-01T00:00:00Z",
+    dag_id: "my_dag",
+    dag_run_id: "run_1",
+    deadline_time: "2026-10-01T00:00:10Z",
+    id: "0199a8e0-0000-7000-8000-00000000000b",
+    missed: true,
+  },
+];
+
+describe("Callbacks", () => {
+  it("lists the run's callbacks and expands a callback's logs in place", async 
() => {
+    vi.mocked(useDeadlinesServiceGetCallbackLogs).mockReturnValue({
+      data: {
+        content: [
+          { event: "Deadline miss callback started", level: "info", timestamp: 
"2026-10-01T00:00:06Z" },
+        ],
+      },
+      error: null,
+      isLoading: false,
+    } as unknown as ReturnType<typeof useDeadlinesServiceGetCallbackLogs>);
+    vi.mocked(useDeadlinesServiceGetDeadlines).mockReturnValue({
+      data: { deadlines, total_entries: deadlines.length },
+      error: null,
+      isFetching: false,
+      isLoading: false,
+    } as unknown as ReturnType<typeof useDeadlinesServiceGetDeadlines>);
+
+    render(
+      <BaseWrapper>
+        <MemoryRouter initialEntries={["/dags/my_dag/runs/run_1/callbacks"]}>
+          <TimezoneProvider>
+            <Routes>
+              <Route element={<Callbacks />} 
path="/dags/:dagId/runs/:runId/callbacks" />
+            </Routes>
+          </TimezoneProvider>
+        </MemoryRouter>
+      </BaseWrapper>,
+    );
+
+    
expect(vi.mocked(useDeadlinesServiceGetDeadlines).mock.lastCall?.[0]).toMatchObject({
+      dagId: "my_dag",
+      dagRunId: "run_1",
+    });
+    expect(screen.getByText("dags.notify.on_miss")).toBeInTheDocument();
+    expect(screen.getByText("sla_alert")).toBeInTheDocument();
+    
expect(screen.getByText(i18n.t("common:states.success"))).toBeInTheDocument();
+    
expect(screen.getByText(i18n.t("common:states.pending"))).toBeInTheDocument();
+
+    const [firstLogsButton] = screen.getAllByRole("button", { name: 
i18n.t("dag:tabs.logs") });
+
+    expect(screen.queryByTestId("table-sub-row")).not.toBeInTheDocument();
+    expect(firstLogsButton).toHaveAttribute("aria-expanded", "false");
+
+    fireEvent.click(firstLogsButton as HTMLElement);
+
+    expect(firstLogsButton).toHaveAttribute("aria-expanded", "true");
+    expect(screen.getAllByTestId("table-sub-row")).toHaveLength(1);
+    
expect(vi.mocked(useDeadlinesServiceGetCallbackLogs).mock.lastCall?.[0]).toMatchObject({
+      callbackId: deadlines[0]?.callback_id,
+    });
+    await waitFor(() => expect(screen.getByText(/Deadline miss callback 
started/u)).toBeInTheDocument());
+
+    fireEvent.click(firstLogsButton as HTMLElement);
+
+    expect(screen.queryByTestId("table-sub-row")).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.tsx
new file mode 100644
index 00000000000..340b314d9b7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Callbacks.tsx
@@ -0,0 +1,161 @@
+/*!
+ * 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 } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import dayjs from "dayjs";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+import { FiChevronDown, FiChevronRight } from "react-icons/fi";
+import { useParams } from "react-router-dom";
+
+import { useDagRunServiceGetDagRun, useDeadlinesServiceGetDeadlines } from 
"openapi/queries";
+import type { DeadlineResponse } from "openapi/requests/types.gen";
+
+import { DataTable } from "src/components/DataTable";
+import type { DataTableFeatures } from "src/components/DataTable/features";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+import { TruncatedText } from "src/components/TruncatedText";
+
+import { type DurationFormat, useAutoRefresh, useDurationFormat } from 
"src/utils";
+
+import { CallbackLogs } from "./CallbackLogs";
+
+const ACTIVE_STATES = new Set(["pending", "queued", "running"]);
+
+const getCallbackColumns = (
+  translate: TFunction,
+  runEndDate: string | null | undefined,
+  renderDuration: DurationFormat["renderDuration"],
+): Array<ColumnDef<DataTableFeatures, DeadlineResponse>> => [
+  {
+    accessorKey: "callback_path",
+    cell: ({ row: { original } }) => <TruncatedText 
text={original.callback_path ?? original.callback_id} />,
+    enableSorting: false,
+    header: translate("callbacks.columns.callback"),
+  },
+  {
+    accessorKey: "callback_type",
+    cell: ({ row: { original } }) =>
+      translate(`callbacks.types.${original.callback_type}`, { defaultValue: 
original.callback_type }),
+    header: translate("callbacks.columns.type"),
+  },
+  {
+    accessorKey: "callback_state",
+    cell: ({ row: { original } }) => (
+      // "pending" is callback-only, so it borrows the "scheduled" badge style.
+      <StateBadge state={original.callback_state === "pending" ? "scheduled" : 
original.callback_state}>
+        {translate(`common:states.${original.callback_state ?? "no_status"}`)}
+      </StateBadge>
+    ),
+    header: translate("common:state"),
+  },
+  {
+    accessorKey: "alert_name",
+    cell: ({ row: { original } }) => original.alert_name ?? "",
+    enableSorting: false,
+    header: translate("callbacks.columns.alertName"),
+  },
+  {
+    accessorKey: "deadline_time",
+    cell: ({ row: { original } }) => <Time datetime={original.deadline_time} 
/>,
+    header: translate("callbacks.columns.deadlineTime"),
+  },
+  {
+    accessorKey: "missed_by",
+    // How late the run finished relative to the deadline, like the run 
header's deadline badge.
+    cell: ({ row: { original } }) => {
+      if (!original.missed) {
+        return "-";
+      }
+      if (runEndDate === null || runEndDate === undefined) {
+        return translate("deadlineStatus.stillRunning");
+      }
+      const diff = dayjs(runEndDate).diff(dayjs(original.deadline_time));
+
+      return diff < 0 ? "-" : (renderDuration(diff / 1000) ?? "-");
+    },
+    enableSorting: false,
+    header: translate("callbacks.columns.missedBy"),
+  },
+  {
+    accessorKey: "logs",
+    cell: ({ row }) => (
+      <Button
+        aria-expanded={row.getIsExpanded()}
+        color="fg.info"
+        onClick={row.getToggleExpandedHandler()}
+        size="xs"
+        variant="ghost"
+      >
+        {row.getIsExpanded() ? <FiChevronDown /> : <FiChevronRight />}
+        {translate("tabs.logs")}
+      </Button>
+    ),
+    enableSorting: false,
+    header: translate("tabs.logs"),
+  },
+];
+
+export const Callbacks = () => {
+  const { t: translate } = useTranslation(["dag", "common"]);
+  const { dagId = "", runId = "" } = useParams();
+  const { setTableURLState, tableURLState } = useTableURLState();
+  const refetchInterval = useAutoRefresh({ dagId });
+  const { renderDuration } = useDurationFormat();
+  // Already loaded (and refreshed while running) by the Run page.
+  const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId: runId 
});
+
+  const { pagination, sorting } = tableURLState;
+  const [sort] = sorting;
+  const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : undefined;
+
+  const { data, error, isLoading } = useDeadlinesServiceGetDeadlines(
+    {
+      dagId,
+      dagRunId: runId,
+      limit: pagination.pageSize,
+      offset: pagination.pageIndex * pagination.pageSize,
+      orderBy,
+    },
+    undefined,
+    {
+      refetchInterval: (query) =>
+        query.state.data?.deadlines.some(({ callback_state: state }) => 
ACTIVE_STATES.has(state ?? "")) &&
+        refetchInterval,
+    },
+  );
+
+  return (
+    <DataTable
+      columns={getCallbackColumns(translate, dagRun?.end_date, renderDuration)}
+      data={data?.deadlines ?? []}
+      errorMessage={<ErrorAlert error={error} />}
+      getRowId={(deadline) => deadline.callback_id}
+      initialState={tableURLState}
+      isLoading={isLoading}
+      modelName="dag:callbacks.callback"
+      onStateChange={setTableURLState}
+      renderSubComponent={(deadline) => <CallbackLogs 
callbackId={deadline.callback_id} />}
+      total={data?.total_entries}
+    />
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Run.test.tsx
new file mode 100644
index 00000000000..1774e84e378
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.test.tsx
@@ -0,0 +1,81 @@
+/*!
+ * 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 type { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+
+import { NavTabs, type NavTab } from "src/layouts/Details/NavTabs";
+
+import i18n from "src/i18n/config";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Run } from "./Run";
+
+vi.mock("openapi/queries", async (importOriginal) => {
+  const actual = await importOriginal<typeof OpenapiQueries>();
+
+  return {
+    ...actual,
+    useDagRunServiceGetDagRun: vi.fn(() => ({ data: undefined, error: null, 
isLoading: false })),
+    useDeadlinesServiceGetDeadlines: vi.fn(),
+  };
+});
+vi.mock("src/hooks/usePluginTabs", () => ({ usePluginTabs: vi.fn(() => []) }));
+vi.mock("src/layouts/Details/DetailsLayout", () => ({
+  DetailsLayout: ({ tabs }: PropsWithChildren<{ readonly tabs: Array<NavTab> 
}>) => <NavTabs tabs={tabs} />,
+}));
+vi.mock("src/utils", async () => {
+  const actual = await vi.importActual("src/utils");
+
+  return { ...actual, useAutoRefresh: vi.fn(() => false), useDocumentTitle: 
vi.fn() };
+});
+
+const { useDeadlinesServiceGetDeadlines } = await import("openapi/queries");
+
+describe("Run", () => {
+  it.each([
+    { tabCount: 0, totalEntries: 0 },
+    { tabCount: 1, totalEntries: 1 },
+  ])(
+    "shows the Callbacks tab only when the run has callbacks ($totalEntries)",
+    ({ tabCount, totalEntries }) => {
+      vi.mocked(useDeadlinesServiceGetDeadlines).mockReturnValue({
+        data: { deadlines: [], total_entries: totalEntries },
+      } as unknown as ReturnType<typeof useDeadlinesServiceGetDeadlines>);
+
+      render(
+        <BaseWrapper>
+          <MemoryRouter initialEntries={["/dags/my_dag/runs/run_1"]}>
+            <Routes>
+              <Route element={<Run />} path="/dags/:dagId/runs/:runId" />
+            </Routes>
+          </MemoryRouter>
+        </BaseWrapper>,
+      );
+
+      
expect(screen.queryAllByTitle(i18n.t("dag:tabs.callbacks"))).toHaveLength(tabCount);
+      
expect(screen.getByTitle(i18n.t("dag:tabs.assetEvents"))).toBeInTheDocument();
+    },
+  );
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index 62966082512..e462975715a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -18,11 +18,11 @@
  */
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase } from "react-icons/fi";
+import { FiCode, FiDatabase, FiPhoneCall } from "react-icons/fi";
 import { MdDetails, MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
 import { useParams } from "react-router-dom";
 
-import { useDagRunServiceGetDagRun } from "openapi/queries";
+import { useDagRunServiceGetDagRun, useDeadlinesServiceGetDeadlines } from 
"openapi/queries";
 
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 
@@ -40,15 +40,6 @@ export const Run = () => {
   // Get external views with dag_run destination
   const externalTabs = usePluginTabs("dag_run");
 
-  const tabs = [
-    { icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value: 
"" },
-    { icon: <FiDatabase />, label: translate("tabs.assetEvents"), value: 
"asset_events" },
-    { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
-    { icon: <FiCode />, label: translate("tabs.code"), value: "code" },
-    { icon: <MdDetails />, label: translate("tabs.details"), value: "details" 
},
-    ...externalTabs,
-  ];
-
   const refetchInterval = useAutoRefresh({ dagId });
 
   const {
@@ -66,6 +57,28 @@ export const Run = () => {
     },
   );
 
+  // Like Required Actions, the Callbacks tab only shows when the run has 
callbacks (one per deadline).
+  const { data: deadlines } = useDeadlinesServiceGetDeadlines(
+    { dagId, dagRunId: runId, limit: 1 },
+    undefined,
+    {
+      refetchInterval: isStatePending(dagRun?.state) && refetchInterval,
+    },
+  );
+  const hasCallbacks = (deadlines?.total_entries ?? 0) > 0;
+
+  const tabs = [
+    { icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value: 
"" },
+    { icon: <FiDatabase />, label: translate("tabs.assetEvents"), value: 
"asset_events" },
+    ...(hasCallbacks
+      ? [{ icon: <FiPhoneCall />, label: translate("tabs.callbacks"), value: 
"callbacks" }]
+      : []),
+    { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
+    { icon: <FiCode />, label: translate("tabs.code"), value: "code" },
+    { icon: <MdDetails />, label: translate("tabs.details"), value: "details" 
},
+    ...externalTabs,
+  ];
+
   return (
     <ReactFlowProvider>
       <DetailsLayout error={error} isLoading={isLoading} tabs={tabs}>
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx 
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index 5233610093d..759fa1a636b 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -25,7 +25,7 @@ import type { TFunction } from "i18next";
 import { useTranslation } from "react-i18next";
 import innerText from "react-innertext";
 
-import { useTaskInstanceServiceGetLog } from "openapi/queries";
+import { useDeadlinesServiceGetCallbackLogs, useTaskInstanceServiceGetLog } 
from "openapi/queries";
 import type {
   StructuredLogMessage,
   TaskInstanceResponse,
@@ -313,6 +313,17 @@ const truncateData = (data: TaskInstancesLogResponse | 
undefined, limit?: number
   };
 };
 
+// Build a 1:1 searchable text array from parsedLogs so search indices align
+// with the rendered output. Each entry maps to exactly one line.
+const getSearchableText = (parsedLogs: Array<ParsedLogEntry> | undefined): 
Array<string> =>
+  (parsedLogs ?? []).map((entry) => {
+    if (typeof entry.element === "string") {
+      return entry.element;
+    }
+
+    return entry.element ? innerText(entry.element) : "";
+  });
+
 export const useLogs = (
   {
     accept = "application/x-ndjson",
@@ -367,15 +378,33 @@ export const useLogs = (
     tryNumber,
   });
 
-  // Build a 1:1 searchable text array from parsedLogs so search indices align
-  // with the rendered output. Each entry maps to exactly one line.
-  const searchableText: Array<string> = (parsedData.parsedLogs ?? 
[]).map((entry) => {
-    if (typeof entry.element === "string") {
-      return entry.element;
-    }
+  const searchableText = getSearchableText(parsedData.parsedLogs);
 
-    return entry.element ? innerText(entry.element) : "";
+  return { parsedData: { ...parsedData, searchableText }, ...rest, 
fetchedData: data };
+};
+
+type CallbackLogsProps = { callbackId: string; dagRunId: string } & Omit<
+  Props,
+  "accept" | "limit" | "taskInstance" | "tryNumber"
+>;
+
+export const useCallbackLogs = ({ callbackId, dagId, dagRunId, 
...displayOptions }: CallbackLogsProps) => {
+  const { t: translate } = useTranslation();
+
+  const { data, ...rest } = useDeadlinesServiceGetCallbackLogs({
+    accept: "application/x-ndjson",
+    callbackId,
+    dagId,
+    dagRunId,
+  });
+
+  const parsedData = parseLogs({
+    data: parseStreamingLogContent(data),
+    ...displayOptions,
+    translate,
+    tryNumber: 1,
   });
+  const searchableText = getSearchableText(parsedData.parsedLogs);
 
   return { parsedData: { ...parsedData, searchableText }, ...rest, 
fetchedData: data };
 };
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index 2b15dcd00aa..13998d22cb8 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -59,6 +59,7 @@ import { Pools } from "src/pages/Pools";
 import { Providers } from "src/pages/Providers";
 import { Run } from "src/pages/Run";
 import { AssetEvents as DagRunAssetEvents } from "src/pages/Run/AssetEvents";
+import { Callbacks as DagRunCallbacks } from "src/pages/Run/Callbacks";
 import { Details as DagRunDetails } from "src/pages/Run/Details";
 import { Security } from "src/pages/Security";
 import { Settings } from "src/pages/Settings";
@@ -250,6 +251,7 @@ export const routerConfig = [
           { element: <Code />, path: "code" },
           { element: <DagRunDetails />, path: "details" },
           { element: <DagRunAssetEvents />, path: "asset_events" },
+          { element: <DagRunCallbacks />, path: "callbacks" },
           pluginRoute,
         ],
         element: <Run />,
diff --git a/airflow-core/src/airflow/utils/log/path_log_reader.py 
b/airflow-core/src/airflow/utils/log/path_log_reader.py
new file mode 100644
index 00000000000..acaf19f1aa0
--- /dev/null
+++ b/airflow-core/src/airflow/utils/log/path_log_reader.py
@@ -0,0 +1,98 @@
+# 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.
+"""Reader for logs not tied to a TaskInstance, looked up by their path 
relative to the log folder."""
+
+from __future__ import annotations
+
+import os
+import re
+from collections.abc import Generator
+from contextlib import suppress
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from airflow.configuration import conf
+from airflow.utils.log.file_task_handler import (
+    FileTaskHandler,
+    StructuredLogMessage,
+    _get_compatible_log_stream,
+    _interleave_logs,
+)
+
+if TYPE_CHECKING:
+    from airflow._shared.logging.remote import LogSourceInfo, RawLogStream, 
StreamingLogResponse
+
+_SAFE_PATH_COMPONENT = re.compile(r"[A-Za-z0-9._:+\-~@]+")
+
+
+def validate_log_path_component(component: str) -> str:
+    """Validate a single log path component, raising ValueError if it could 
escape the log folder."""
+    if component in (".", "..") or not 
_SAFE_PATH_COMPONENT.fullmatch(component):
+        raise ValueError(f"Invalid log path component: {component!r}")
+    return component
+
+
+def read_logs_at_path(relative_path: str) -> Generator[StructuredLogMessage, 
None, None]:
+    """
+    Stream logs not tied to a TaskInstance, trying remote storage first and 
then the local filesystem.
+
+    The path is relative to the log folder. Callers build it and must validate 
any user-supplied
+    parts with ``validate_log_path_component``. Deadline callbacks are the 
current user.
+    """
+    sources: LogSourceInfo = []
+    log_streams: list[RawLogStream] = []
+
+    with suppress(Exception):
+        sources, log_streams = _read_remote_logs(relative_path)
+
+    if not log_streams:
+        local_sources, log_streams = _read_local_logs(relative_path)
+        sources = [*sources, *local_sources]
+
+    if not log_streams:
+        yield StructuredLogMessage(event="No logs found.")
+        return
+
+    yield StructuredLogMessage(event="::group::Log message source details", 
sources=sources)  # type: ignore[call-arg]
+    yield StructuredLogMessage(event="::endgroup::")
+    yield from _interleave_logs(*log_streams)
+
+
+def _read_remote_logs(relative_path: str) -> StreamingLogResponse:
+    from airflow.logging_config import get_remote_task_log
+
+    remote_io = get_remote_task_log()
+    if remote_io is None:
+        return [], []
+
+    # Prefer .stream(), which yields the remote log lazily, over .read(), 
which loads it all into memory.
+    # These logs have no TaskInstance, so ti is None.
+    if stream_method := getattr(remote_io, "stream", None):
+        sources, logs = stream_method(relative_path, None)
+        return sources, logs or []
+
+    sources, logs = remote_io.read(relative_path, None)
+    if not logs:
+        return sources, []
+
+    return sources, [_get_compatible_log_stream(logs)]
+
+
+def _read_local_logs(relative_path: str) -> StreamingLogResponse:
+    """Read with the task handler's symlink-safe local reader."""
+    base_log_folder = os.path.realpath(conf.get("logging", "base_log_folder"))
+    return 
FileTaskHandler(base_log_folder)._read_from_local(Path(base_log_folder, 
relative_path))
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
index dd10f3ded99..3943b9cc9b4 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
@@ -17,16 +17,22 @@
 
 from __future__ import annotations
 
+import json
+import uuid
 from datetime import timedelta
+from unittest import mock
 from unittest.mock import Mock
 
 import pytest
 from fastapi import HTTPException
-from sqlalchemy import select
+from sqlalchemy import select, update
 
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.core_api.routes.ui.deadlines import 
get_dag_deadline_alerts
+from airflow.logging.remote import RemoteLogStreamIO
+from airflow.models.callback import Callback, CallbackType
 from airflow.models.dag_version import DagVersion
+from airflow.models.dagrun import DagRun
 from airflow.models.deadline import Deadline
 from airflow.models.deadline_alert import DeadlineAlert
 from airflow.models.serialized_dag import SerializedDagModel
@@ -68,6 +74,12 @@ ALERT_NAME = "SLA Breach Alert"
 _CALLBACK_PATH = 
"tests.unit.api_fastapi.core_api.routes.ui.test_deadlines._noop_callback"
 
 
+_CALLBACK_LOG_FILES = [
+    (CallbackType.EXECUTOR, "executor_callbacks/{dag_id}/{run_id}/{cb_id}"),
+    (CallbackType.TRIGGERER, 
"triggerer_callbacks/{dag_id}/{run_id}/{cb_id}/callback.log"),
+]
+
+
 async def _noop_callback(**kwargs):
     """No-op callback used to satisfy Deadline creation in tests."""
 
@@ -261,6 +273,10 @@ class TestGetDagRunDeadlines:
         assert deadline1["alert_id"] is None
         assert deadline1["dag_id"] == DAG_ID
         assert deadline1["dag_run_id"] == RUN_SINGLE
+        assert deadline1["callback_type"] == "triggerer"
+        assert deadline1["callback_state"] == "scheduled"
+        assert deadline1["callback_path"] == _CALLBACK_PATH
+        assert "callback_id" in deadline1
         assert "id" in deadline1
         assert "created_at" in deadline1
 
@@ -293,8 +309,8 @@ class TestGetDagRunDeadlines:
 
     @pytest.mark.parametrize(
         "order_by",
-        ["deadline_time", "id", "created_at", "alert_name"],
-        ids=["deadline_time", "id", "created_at", "alert_name"],
+        ["deadline_time", "id", "created_at", "alert_name", "callback_state"],
+        ids=["deadline_time", "id", "created_at", "alert_name", 
"callback_state"],
     )
     def test_should_response_200_order_by(self, test_client, order_by):
         url = f"/dags/{DAG_ID}/dagRuns/{RUN_MULTI}/deadlines"
@@ -711,3 +727,106 @@ class TestDeadlineAlertsIntervalSerialization:
         assert response.status_code == 200
         alerts = {a["name"]: a for a in response.json()["deadline_alerts"]}
         assert alerts["dynamic_interval_alert"]["interval"] is None
+
+
+class TestGetCallbackLogs:
+    """Tests for GET 
/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs."""
+
+    @pytest.fixture
+    def missed_callback_id(self, session):
+        deadline = 
session.scalar(select(Deadline).join(Deadline.dagrun).where(DagRun.run_id == 
RUN_MISSED))
+        return str(deadline.callback_id)
+
+    @pytest.fixture
+    def set_callback_type(self, session, missed_callback_id):
+        def _set(callback_type: CallbackType) -> None:
+            session.execute(
+                update(Callback)
+                .where(Callback.id == uuid.UUID(missed_callback_id))
+                .values(type=callback_type)
+            )
+            session.commit()
+
+        return _set
+
+    @pytest.mark.parametrize("accept", ["application/json", 
"application/x-ndjson"])
+    @pytest.mark.parametrize(
+        ("callback_type", "log_file"), _CALLBACK_LOG_FILES, ids=["executor", 
"triggerer"]
+    )
+    def test_returns_logs_from_local_storage(
+        self, test_client, missed_callback_id, set_callback_type, tmp_path, 
accept, callback_type, log_file
+    ):
+        set_callback_type(callback_type)
+        log_path = tmp_path / log_file.format(dag_id=DAG_ID, 
run_id=RUN_MISSED, cb_id=missed_callback_id)
+        log_path.parent.mkdir(parents=True)
+        log_path.write_text("callback ran\n")
+
+        with conf_vars({("logging", "base_log_folder"): str(tmp_path)}):
+            response = test_client.get(
+                
f"/dags/{DAG_ID}/dagRuns/{RUN_MISSED}/callbacks/{missed_callback_id}/logs",
+                headers={"Accept": accept},
+            )
+        assert response.status_code == 200
+        assert response.headers["content-type"].startswith(accept)
+        if accept == "application/x-ndjson":
+            entries = [json.loads(line) for line in response.text.splitlines() 
if line]
+        else:
+            entries = response.json()["content"]
+        assert "callback ran" in [entry["event"] for entry in entries]
+
+    @pytest.mark.parametrize(
+        ("callback_type", "log_file"), _CALLBACK_LOG_FILES, ids=["executor", 
"triggerer"]
+    )
+    @mock.patch("airflow.logging_config.get_remote_task_log")
+    def test_reads_remote_log_at_the_path_for_the_callback_type(
+        self,
+        mock_get_remote_task_log,
+        test_client,
+        missed_callback_id,
+        set_callback_type,
+        callback_type,
+        log_file,
+    ):
+        # Like CloudWatch, the remote IO returns a lazy stream for any path, 
even one that has no log.
+        def lines(relative_path):
+            yield json.dumps({"event": f"remote log at {relative_path}"}) + 
"\n"
+
+        remote_io = mock.create_autospec(RemoteLogStreamIO, instance=True)
+        remote_io.stream.side_effect = lambda relative_path, ti: 
([relative_path], [lines(relative_path)])
+        mock_get_remote_task_log.return_value = remote_io
+        set_callback_type(callback_type)
+
+        response = 
test_client.get(f"/dags/{DAG_ID}/dagRuns/{RUN_MISSED}/callbacks/{missed_callback_id}/logs")
+
+        assert response.status_code == 200
+        expected_path = log_file.format(dag_id=DAG_ID, run_id=RUN_MISSED, 
cb_id=missed_callback_id)
+        remote_io.stream.assert_called_once_with(expected_path, None)
+        assert f"remote log at {expected_path}" in [entry["event"] for entry 
in response.json()["content"]]
+
+    @pytest.mark.parametrize(
+        ("run_id", "known_callback"),
+        [(RUN_MISSED, False), (RUN_SINGLE, True)],
+        ids=["unknown_callback", "callback_of_other_run"],
+    )
+    def test_should_response_404(self, test_client, missed_callback_id, 
run_id, known_callback):
+        callback_id = missed_callback_id if known_callback else uuid.uuid4()
+        response = 
test_client.get(f"/dags/{DAG_ID}/dagRuns/{run_id}/callbacks/{callback_id}/logs")
+        assert response.status_code == 404
+
+    # HTTP clients normalize away a literal "..", so only encoded or otherwise 
unsafe values reach the check.
+    @pytest.mark.parametrize("bad_run_id", ["%2e%2e", "..%5c..%5cetc", 
"run%20id"])
+    def test_path_traversal_in_dag_run_id_returns_400(self, test_client, 
missed_callback_id, bad_run_id):
+        response = 
test_client.get(f"/dags/{DAG_ID}/dagRuns/{bad_run_id}/callbacks/{missed_callback_id}/logs")
+        assert response.status_code == 400
+
+    def test_should_response_401(self, unauthenticated_test_client):
+        response = unauthenticated_test_client.get(
+            
f"/dags/{DAG_ID}/dagRuns/{RUN_MISSED}/callbacks/{uuid.uuid4()}/logs"
+        )
+        assert response.status_code == 401
+
+    def test_should_response_403(self, unauthorized_test_client):
+        response = unauthorized_test_client.get(
+            
f"/dags/{DAG_ID}/dagRuns/{RUN_MISSED}/callbacks/{uuid.uuid4()}/logs"
+        )
+        assert response.status_code == 403
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
index 85f09f3675d..43f28bcba5f 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
@@ -71,6 +71,7 @@ class TestStreamingEndpointSessionScope:
         allowed = {
             "airflow.api_fastapi.core_api.routes.public.log.get_log",
             
"airflow.api_fastapi.core_api.routes.public.dag_run.wait_dag_run_until_finished",
+            
"airflow.api_fastapi.core_api.routes.ui.deadlines.get_callback_logs",
         }
 
         app = create_app()
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 212a40aa529..ab6a9e6f4f1 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -876,6 +876,37 @@ def 
test_load_triggers_survives_task_missing_from_resolved_dag_version(superviso
     assert "Task not found in resolved Dag version; building plain workload" 
in caplog
 
 
+def 
test_create_workload_sets_up_logging_for_callback_trigger(jobless_supervisor, 
mocker):
+    """_create_workload() populates logger_cache for callback triggers so 
their logs are captured."""
+    callback_id = uuid.uuid4()
+    callback = mocker.Mock()
+    callback.id = callback_id
+    callback.data = {
+        "dag_id": "test_dag",
+        "kwargs": {"context": {"dag_run": {"dag_id": "test_dag", "dag_run_id": 
"manual__2024-01-01"}}},
+    }
+
+    trigger = mocker.Mock()
+    trigger.id = 8
+    trigger.classpath = "airflow.triggers.callback.CallbackTrigger"
+    trigger.encrypted_kwargs = ""
+    trigger.task_instance = None
+    trigger.assets = None
+    trigger.callback = callback
+
+    workload = jobless_supervisor._create_workload(
+        trigger=trigger,
+        dag_bag=mocker.Mock(),
+        render_log_fname=mocker.Mock(),
+        session=mocker.Mock(),
+    )
+
+    assert workload is not None
+    factory = jobless_supervisor.logger_cache[trigger.id]
+    assert factory.log_path == 
f"triggerer_callbacks/test_dag/manual__2024-01-01/{callback_id}/callback.log"
+    assert factory.ti is None
+
+
 def 
test_create_workload_sets_watched_assets_for_asset_only_trigger(jobless_supervisor,
 mocker):
     """_create_workload() should populate watched_assets when 
trigger.task_instance is None and assets exist."""
     asset1 = mocker.Mock(spec=Asset)
diff --git a/airflow-core/tests/unit/utils/log/test_path_log_reader.py 
b/airflow-core/tests/unit/utils/log/test_path_log_reader.py
new file mode 100644
index 00000000000..92b169c2b6b
--- /dev/null
+++ b/airflow-core/tests/unit/utils/log/test_path_log_reader.py
@@ -0,0 +1,76 @@
+# 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.
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from airflow.utils.log.path_log_reader import read_logs_at_path, 
validate_log_path_component
+
+from tests_common.test_utils.config import conf_vars
+
+PATH = "executor_callbacks/dag1/run1/cb1"
+
+
+class TestValidateLogPathComponent:
+    @pytest.mark.parametrize("component", ["my_dag", 
"manual__2024-01-01T00:00:00+00:00", "abc.123~x@y"])
+    def test_valid_components_pass(self, component):
+        assert validate_log_path_component(component) == component
+
+    @pytest.mark.parametrize("component", ["..", ".", "a/b", "a\\b", "", "a 
b", "../etc"])
+    def test_unsafe_components_raise(self, component):
+        with pytest.raises(ValueError, match="Invalid log path component"):
+            validate_log_path_component(component)
+
+
+class TestReadLogsAtPath:
+    @pytest.fixture(autouse=True)
+    def log_folder(self, tmp_path):
+        with conf_vars({("logging", "base_log_folder"): str(tmp_path / 
"logs")}):
+            yield tmp_path / "logs"
+
+    def test_no_logs_found_yields_message(self):
+        assert [m.event for m in read_logs_at_path(PATH)] == ["No logs found."]
+
+    def test_reads_local_logs(self, log_folder):
+        log_dir = log_folder / "executor_callbacks" / "dag1" / "run1"
+        log_dir.mkdir(parents=True)
+        (log_dir / "cb1").write_text("local line\n")
+
+        assert "local line" in [m.event for m in read_logs_at_path(PATH)]
+
+    def test_symlink_escaping_log_folder_is_skipped(self, log_folder, 
tmp_path):
+        secret = tmp_path / "secret"
+        secret.write_text("secret data\n")
+        log_dir = log_folder / "executor_callbacks" / "dag1" / "run1"
+        log_dir.mkdir(parents=True)
+        (log_dir / "cb1").symlink_to(secret)
+
+        assert [m.event for m in read_logs_at_path(PATH)] == ["No logs found."]
+
+    def test_remote_logs_used_when_available(self):
+        def one_stream():
+            yield '{"event": "remote line"}\n'
+
+        with patch(
+            "airflow.utils.log.path_log_reader._read_remote_logs",
+            return_value=(["s3://bucket/log"], [one_stream()]),
+        ):
+            msgs = list(read_logs_at_path(PATH))
+
+        assert any(m.event == "remote line" for m in msgs)
diff --git 
a/providers/alibaba/src/airflow/providers/alibaba/cloud/log/oss_task_handler.py 
b/providers/alibaba/src/airflow/providers/alibaba/cloud/log/oss_task_handler.py
index d679f5cf251..fd16d6bb970 100644
--- 
a/providers/alibaba/src/airflow/providers/alibaba/cloud/log/oss_task_handler.py
+++ 
b/providers/alibaba/src/airflow/providers/alibaba/cloud/log/oss_task_handler.py
@@ -112,7 +112,7 @@ class OSSRemoteLogIO(LoggingMixin):  # noqa: D101
                 remote_conn_id,
             )
 
-    def read(self, relative_path, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages | None]:
+    def read(self, relative_path, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages | None]:
         logs: list[str] = []
         messages = [relative_path]
 
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
index d9d0b3f52f4..192d8d10566 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
@@ -251,13 +251,13 @@ class CloudWatchRemoteLogIO(LoggingMixin):  # noqa: D101
                 if parent.exists():
                     self.log.warning("Failed to delete local log dir: %s", 
parent)
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> LogResponse:
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> LogResponse:
         messages, logs = self.stream(relative_path, ti)
         str_logs: list[str] = [f"{msg}\n" for group in logs for msg in group]
 
         return messages, str_logs
 
-    def stream(self, relative_path: str, ti: RuntimeTI) -> 
StreamingLogResponse:
+    def stream(self, relative_path: str, ti: RuntimeTI | None) -> 
StreamingLogResponse:
         logs: list[RawLogStream] = []
         messages = [
             f"Reading remote log from Cloudwatch log_group: {self.log_group} 
log_stream: {relative_path}"
@@ -274,7 +274,7 @@ class CloudWatchRemoteLogIO(LoggingMixin):  # noqa: D101
         return messages, logs
 
     def get_cloudwatch_logs(
-        self, stream_name: str, task_instance: RuntimeTI
+        self, stream_name: str, task_instance: RuntimeTI | None
     ) -> Generator[CloudWatchLogEvent, None, None]:
         """
         Return all logs from the given log stream.
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py 
b/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
index 705b8d53931..32b0b1ee4dd 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py
@@ -198,7 +198,7 @@ class S3RemoteLogIO(LoggingMixin):  # noqa: D101
                     return False
         return True
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages | None]:
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages | None]:
         logs: list[str] = []
         messages = []
         bucket, prefix = 
self.hook.parse_s3_url(s3url=os.path.join(self.remote_base, relative_path))
diff --git 
a/providers/apache/hdfs/src/airflow/providers/apache/hdfs/log/hdfs_task_handler.py
 
b/providers/apache/hdfs/src/airflow/providers/apache/hdfs/log/hdfs_task_handler.py
index 5694c62602a..c6ac2060974 100644
--- 
a/providers/apache/hdfs/src/airflow/providers/apache/hdfs/log/hdfs_task_handler.py
+++ 
b/providers/apache/hdfs/src/airflow/providers/apache/hdfs/log/hdfs_task_handler.py
@@ -92,7 +92,7 @@ class HdfsRemoteLogIO(LoggingMixin):  # noqa: D101
         """Returns WebHDFSHook."""
         return WebHDFSHook(webhdfs_conn_id=conf.get("logging", 
"REMOTE_LOG_CONN_ID"))
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages]:
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages]:
         logs = []
         messages = []
         file_path = os.path.join(self.remote_base, relative_path)
diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
index 24320cece00..2e9e84e569e 100644
--- 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
+++ 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
@@ -863,7 +863,10 @@ class ElasticsearchRemoteLogIO(LoggingMixin):  # noqa: D101
             self.log.exception("Unable to insert logs into Elasticsearch. 
Reason: %s", str(e))
             return False
 
-    def read(self, _relative_path: str, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages]:
+    def read(self, _relative_path: str, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages]:
+        if ti is None:
+            # Logs are keyed by task instance, and ``upload`` skips logs 
without one.
+            return [], []
         log_id = _render_log_id(self.log_id_template, ti, ti.try_number)  # 
type: ignore[arg-type]
         self.log.info("Reading log %s from Elasticsearch", log_id)
         offset = 0
diff --git 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
index b0b4e39d997..a54af43babb 100644
--- 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
+++ 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
@@ -930,6 +930,9 @@ class TestElasticsearchRemoteLogIO:
     def test_upload_returns_early_when_ti_is_none(self, tmp_json_file):
         self.elasticsearch_io.upload(tmp_json_file, ti=None)
 
+    def test_read_returns_no_logs_when_ti_is_none(self):
+        assert self.elasticsearch_io.read("executor_callbacks/dag/run/cb", 
None) == ([], [])
+
 
 class TestFormatErrorDetail:
     def test_returns_none_for_empty(self):
diff --git 
a/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
 
b/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
index 1a9a2b296c7..6c6e5cf9e24 100644
--- 
a/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
+++ 
b/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
@@ -248,8 +248,11 @@ class CloudLoggingRemoteLogIO(LoggingMixin):
             if parent.exists():
                 shutil.rmtree(parent, ignore_errors=True)
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> LogResponse:
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> LogResponse:
         """Read logs from Cloud Logging using task instance labels."""
+        if ti is None:
+            # Entries are found by task instance labels, so logs without a 
task instance can't be read.
+            return [], None
         ti_labels = _task_instance_to_labels(ti)
         log_filter = self.prepare_log_filter(ti_labels)
         messages, end_of_log, _ = self.read_logs(log_filter, 
next_page_token=None, all_pages=True)
diff --git 
a/providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py 
b/providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py
index 2a7e2282957..8042621eb02 100644
--- 
a/providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py
+++ 
b/providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py
@@ -209,7 +209,7 @@ class GCSRemoteLogIO(LoggingMixin):  # noqa: D101
             exc, "resp", {}
         ).get("status") == "404"
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> LogResponse:
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> LogResponse:
         messages, log_streams = self.stream(relative_path, ti)
         if not log_streams:
             return messages, None
@@ -224,7 +224,7 @@ class GCSRemoteLogIO(LoggingMixin):  # noqa: D101
 
         return messages, logs
 
-    def stream(self, relative_path: str, ti: RuntimeTI) -> 
StreamingLogResponse:
+    def stream(self, relative_path: str, ti: RuntimeTI | None) -> 
StreamingLogResponse:
         messages: list[str] = []
         log_streams: list[RawLogStream] = []
         remote_loc = os.path.join(self.remote_base, relative_path)
diff --git 
a/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
 
b/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
index bdbb9889f77..33750727c1c 100644
--- 
a/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
+++ 
b/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
@@ -152,6 +152,11 @@ class TestCloudLoggingRemoteLogIO:
             delete_local_copy=True,
         )
 
+    
@mock.patch("airflow.providers.google.cloud.log.cloud_logging_task_handler.LoggingServiceV2Client")
+    def test_read_returns_no_logs_when_ti_is_none(self, mock_client):
+        assert self.io.read("executor_callbacks/dag/run/cb", None) == ([], 
None)
+        mock_client.assert_not_called()
+
     @mock.patch(
         
"airflow.providers.google.cloud.log.cloud_logging_task_handler.get_credentials_and_project_id"
     )
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/log/wasb_task_handler.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/log/wasb_task_handler.py
index 284517cf349..d00c9143bb3 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/log/wasb_task_handler.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/log/wasb_task_handler.py
@@ -153,7 +153,7 @@ class WasbRemoteLogIO(LoggingMixin):  # noqa: D101
 
         return self._build_legacy_url(blob_name)
 
-    def read(self, relative_path, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages | None]:
+    def read(self, relative_path, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages | None]:
         messages = []
         logs = []
         # TODO: fix this - "relative path" i.e currently 
REMOTE_BASE_LOG_FOLDER should start with "wasb"
diff --git 
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py 
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
index 96c27c1d955..e8b1859585b 100644
--- 
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
+++ 
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
@@ -1032,7 +1032,10 @@ class OpensearchRemoteLogIO(LoggingMixin):  # noqa: D101
             self.log.exception("Unable to insert logs into OpenSearch. Reason: 
%s", str(e))
             return False
 
-    def read(self, _relative_path: str, ti: RuntimeTI) -> tuple[LogSourceInfo, 
LogMessages]:
+    def read(self, _relative_path: str, ti: RuntimeTI | None) -> 
tuple[LogSourceInfo, LogMessages]:
+        if ti is None:
+            # Logs are keyed by task instance, and ``upload`` skips logs 
without one.
+            return [], []
         log_id = _render_log_id(self.log_id_template, ti, ti.try_number)  # 
type: ignore[arg-type]
         self.log.info("Reading log %s from Opensearch", log_id)
         responses = []
diff --git 
a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py 
b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
index 71bbecd1580..54c3442e418 100644
--- a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
+++ b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
@@ -865,6 +865,9 @@ class TestOpensearchRemoteLogIO:
         assert response is None
         mock_exception.assert_called_once()
 
+    def test_read_returns_no_logs_when_ti_is_none(self):
+        assert self.opensearch_io.read("executor_callbacks/dag/run/cb", None) 
== ([], [])
+
     def test_read_returns_missing_log_message_when_os_read_returns_none(self, 
ti):
         with patch.object(self.opensearch_io, "_os_read", return_value=None):
             log_source_info, log_messages = self.opensearch_io.read("", ti)
diff --git a/shared/logging/src/airflow_shared/logging/remote.py 
b/shared/logging/src/airflow_shared/logging/remote.py
index b80a3ceb9cd..1d0250487a0 100644
--- a/shared/logging/src/airflow_shared/logging/remote.py
+++ b/shared/logging/src/airflow_shared/logging/remote.py
@@ -60,8 +60,12 @@ class RemoteLogIO(Protocol):
         """Upload the given log path to the remote storage."""
         ...
 
-    def read(self, relative_path: str, ti: RuntimeTI) -> LogResponse:
-        """Read logs from the given remote log path."""
+    def read(self, relative_path: str, ti: RuntimeTI | None) -> LogResponse:
+        """
+        Read logs from the given remote log path.
+
+        ``ti`` is None for logs not tied to a task instance, such as deadline 
callbacks.
+        """
         ...
 
 
@@ -69,8 +73,12 @@ class RemoteLogIO(Protocol):
 class RemoteLogStreamIO(RemoteLogIO, Protocol):
     """Interface for remote task loggers with stream-based read support."""
 
-    def stream(self, relative_path: str, ti: RuntimeTI) -> 
StreamingLogResponse:
-        """Stream-based read interface for reading logs from the given remote 
log path."""
+    def stream(self, relative_path: str, ti: RuntimeTI | None) -> 
StreamingLogResponse:
+        """
+        Stream-based read interface for reading logs from the given remote log 
path.
+
+        ``ti`` is None for logs not tied to a task instance, such as deadline 
callbacks.
+        """
         ...
 
 

Reply via email to