Skip to content

Commit fd02cd8

Browse files
authored
feat: Add redirected actor logs (#403)
### Description - Add convenience methods and arguments for redirecting streamed actor log into another logger. - This changes the default behavior in non breaking way. By default the logs will be redirected now. - Update log endpoints calls to accept `raw` parameter. ### Issues - Closes: #402
1 parent f62b80f commit fd02cd8

File tree

7 files changed

+733
-44
lines changed

7 files changed

+733
-44
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
keywords = ["apify", "api", "client", "automation", "crawling", "scraping"]
2626
dependencies = [
2727
"apify-shared>=1.4.1",
28+
"colorama~=0.4.0",
2829
"httpx>=0.25",
2930
"more_itertools>=10.0.0",
3031
]
@@ -52,6 +53,7 @@ dev = [
5253
"respx~=0.22.0",
5354
"ruff~=0.11.0",
5455
"setuptools", # setuptools are used by pytest but not explicitly required
56+
"types-colorama~=0.4.15.20240106",
5557
]
5658

5759
[tool.hatch.build.targets.wheel]

src/apify_client/_logging.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from contextvars import ContextVar
88
from typing import TYPE_CHECKING, Any, Callable, NamedTuple
99

10+
from colorama import Fore, Style
11+
1012
# Conditional import only executed when type checking, otherwise we'd get circular dependency issues
1113
if TYPE_CHECKING:
1214
from apify_client.clients.base.base_client import _BaseBaseClient
@@ -120,3 +122,47 @@ def format(self, record: logging.LogRecord) -> str:
120122
if extra:
121123
log_string = f'{log_string} ({json.dumps(extra)})'
122124
return log_string
125+
126+
127+
def create_redirect_logger(
128+
name: str,
129+
) -> logging.Logger:
130+
"""Create a logger for redirecting logs from another Actor.
131+
132+
Args:
133+
name: The name of the logger. It can be used to inherit from other loggers. Example: `apify.xyz` will use logger
134+
named `xyz` and make it a children of `apify` logger.
135+
136+
Returns:
137+
The created logger.
138+
"""
139+
to_logger = logging.getLogger(name)
140+
to_logger.propagate = False
141+
142+
# Remove filters and handlers in case this logger already exists and was set up in some way.
143+
for handler in to_logger.handlers:
144+
to_logger.removeHandler(handler)
145+
for log_filter in to_logger.filters:
146+
to_logger.removeFilter(log_filter)
147+
148+
handler = logging.StreamHandler()
149+
handler.setFormatter(RedirectLogFormatter())
150+
to_logger.addHandler(handler)
151+
to_logger.setLevel(logging.DEBUG)
152+
return to_logger
153+
154+
155+
class RedirectLogFormatter(logging.Formatter):
156+
"""Formater applied to default redirect logger."""
157+
158+
def format(self, record: logging.LogRecord) -> str:
159+
"""Format the log by prepending logger name to the original message.
160+
161+
Args:
162+
record: Log record to be formated.
163+
164+
Returns:
165+
Formated log message.
166+
"""
167+
formated_logger_name = f'{Fore.CYAN}[{record.name}]{Style.RESET_ALL} '
168+
return f'{formated_logger_name}-> {record.msg}'

src/apify_client/clients/resource_clients/actor.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, Literal
44

55
from apify_shared.utils import (
66
filter_out_none_values_recursively,
@@ -27,6 +27,7 @@
2727

2828
if TYPE_CHECKING:
2929
from decimal import Decimal
30+
from logging import Logger
3031

3132
from apify_shared.consts import ActorJobStatus, MetaOrigin
3233

@@ -289,6 +290,7 @@ def call(
289290
timeout_secs: int | None = None,
290291
webhooks: list[dict] | None = None,
291292
wait_secs: int | None = None,
293+
logger: Logger | None | Literal['default'] = 'default',
292294
) -> dict | None:
293295
"""Start the Actor and wait for it to finish before returning the Run object.
294296
@@ -313,6 +315,9 @@ def call(
313315
a webhook set up for the Actor, you do not have to add it again here.
314316
wait_secs: The maximum number of seconds the server waits for the run to finish. If not provided,
315317
waits indefinitely.
318+
logger: Logger used to redirect logs from the Actor run. Using "default" literal means that a predefined
319+
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
320+
will redirect logs to the provided logger.
316321
317322
Returns:
318323
The run object.
@@ -327,8 +332,17 @@ def call(
327332
timeout_secs=timeout_secs,
328333
webhooks=webhooks,
329334
)
335+
if not logger:
336+
return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
330337

331-
return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
338+
run_client = self.root_client.run(run_id=started_run['id'])
339+
if logger == 'default':
340+
log_context = run_client.get_streamed_log()
341+
else:
342+
log_context = run_client.get_streamed_log(to_logger=logger)
343+
344+
with log_context:
345+
return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
332346

333347
def build(
334348
self,
@@ -681,6 +695,7 @@ async def call(
681695
timeout_secs: int | None = None,
682696
webhooks: list[dict] | None = None,
683697
wait_secs: int | None = None,
698+
logger: Logger | None | Literal['default'] = 'default',
684699
) -> dict | None:
685700
"""Start the Actor and wait for it to finish before returning the Run object.
686701
@@ -705,6 +720,9 @@ async def call(
705720
a webhook set up for the Actor, you do not have to add it again here.
706721
wait_secs: The maximum number of seconds the server waits for the run to finish. If not provided,
707722
waits indefinitely.
723+
logger: Logger used to redirect logs from the Actor run. Using "default" literal means that a predefined
724+
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
725+
will redirect logs to the provided logger.
708726
709727
Returns:
710728
The run object.
@@ -720,7 +738,17 @@ async def call(
720738
webhooks=webhooks,
721739
)
722740

723-
return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
741+
if not logger:
742+
return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
743+
744+
run_client = self.root_client.run(run_id=started_run['id'])
745+
if logger == 'default':
746+
log_context = await run_client.get_streamed_log()
747+
else:
748+
log_context = await run_client.get_streamed_log(to_logger=logger)
749+
750+
async with log_context:
751+
return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs)
724752

725753
async def build(
726754
self,

0 commit comments

Comments
 (0)