Skip to content

Commit 9e4097d

Browse files
committed
Automatically send telemetry for failed subprocesses
1 parent 204ce20 commit 9e4097d

File tree

20 files changed

+638
-76
lines changed

20 files changed

+638
-76
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
## Unreleased
1010

11+
***Changed:***
12+
13+
- The `app.subprocess.run` method now uses a pseudo-terminal in order to capture output from subprocesses while displaying it. A new `app.subprocess.attach` method is available which retains the original behavior and should be preferred when subprocesses require user interaction.
14+
- The `app.subprocess.run` method now returns an integer representing the exit code of the completed subprocess call
15+
16+
***Added:***
17+
18+
- Automatically send telemetry for failed subprocesses
19+
1120
***Fixed:***
1221

1322
- Properly apply Python path modifications when loading dynamic commands

docs/reference/api/app.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- http
1313
- tools
1414
- telemetry
15+
- last_error
1516
- display
1617
- display_critical
1718
- display_error

docs/reference/api/process.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- capture
1010
- wait
1111
- exit_with
12+
- attach
1213
- spawn_daemon
1314

1415
::: dda.utils.process.EnvVars

docs/reference/api/telemetry.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
::: dda.telemetry.manager.TelemetryManager
66
options:
77
members:
8+
- enabled
89
- log
910
- trace
1011

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"packaging",
3838
"platformdirs~=4.2",
3939
"psutil~=7.0",
40+
"pywinpty~=2.0.15; sys_platform == 'win32'",
4041
"rich~=14.0",
4142
"rich-click~=1.8",
4243
"tomlkit~=0.13",

src/dda/cli/application.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,36 @@ def __init__(self, *, terminator: Callable[[int], NoReturn], config_file: Config
5656
def abort(self, text: str = "", code: int = 1) -> NoReturn:
5757
"""
5858
Gracefully terminate the application with an optional
59-
[error message][dda.cli.application.Application.display_critical].
59+
[error message][dda.cli.application.Application.display_critical]. The message is
60+
appended to the [last error message][dda.cli.application.Application.last_error].
6061
6162
Parameters:
6263
text: The error message to display.
6364
code: The exit code to use.
6465
"""
6566
if text:
67+
self.last_error += text
6668
self.display_critical(text)
6769

6870
self.__terminator(code)
6971

72+
@cached_property
73+
def last_error(self) -> str:
74+
"""
75+
The last recorded error message which will be collected as telemetry. This can be overwritten like so:
76+
77+
```python
78+
app.last_error = "An error occurred"
79+
```
80+
81+
Alternatively, you can append to it:
82+
83+
```python
84+
app.last_error += "\\nExtra information or context"
85+
```
86+
"""
87+
return ""
88+
7089
@cached_property
7190
def config_file(self) -> ConfigFile:
7291
return self.__config_file

src/dda/cli/base.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,32 +111,47 @@ def __exit__(
111111
if (
112112
# The application may not be set if an error occurred very early
113113
app is not None
114+
and app.telemetry.enabled
114115
# The proper exit code only manifests when the top level context exits
115116
and command_depth == 1
116117
and self._depth == 1
117118
):
118-
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L296
119119
if isinstance(exc_value, Exit):
120+
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L296
120121
exit_code = exc_value.exit_code
121-
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L64
122122
elif isinstance(exc_value, UsageError):
123+
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L64
123124
exit_code = 2
124-
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L29
125+
elif isinstance(exc_value, KeyboardInterrupt):
126+
# Use the non-Windows default value for consistency
127+
# https://www.redhat.com/en/blog/exit-codes-demystified
128+
# https://github.com/python/cpython/blob/3.13/Modules/main.c#L731-L754
129+
exit_code = 130
125130
else:
131+
import traceback
132+
133+
# https://github.com/pallets/click/blob/8.1.8/src/click/exceptions.py#L29
126134
exit_code = 1
135+
app.last_error = traceback.format_exc()
127136

128137
from dda.cli import START_TIME, START_TIMESTAMP
129138
from dda.utils.platform import join_command_args
130139

140+
metadata = {
141+
"cli.command": join_command_args(sys.argv[1:]),
142+
"cli.exit_code": str(exit_code),
143+
}
144+
if last_error := app.last_error.strip():
145+
# Payload limit is 5MB so we truncate the error message to a little bit less than that
146+
message_max_length = int(1024 * 1024 * 4.5)
147+
metadata["error.message"] = last_error[-message_max_length:]
148+
131149
app.telemetry.trace.span({
132150
"resource": " ".join(root_ctx.deepest_command_path.split()[1:]) or " ",
133151
"start": START_TIMESTAMP,
134152
"duration": perf_counter_ns() - START_TIME,
135153
"error": 0 if exit_code == 0 else 1,
136-
"meta": {
137-
"cli.command": join_command_args(sys.argv[1:]),
138-
"cli.exit_code": str(exit_code),
139-
},
154+
"meta": metadata,
140155
})
141156

142157
super().__exit__(exc_type, exc_value, tb)

src/dda/telemetry/manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def __init__(self, app: Application) -> None:
2424

2525
self.__started = False
2626

27+
@property
28+
def enabled(self) -> bool:
29+
"""
30+
Whether the user has consented to telemetry.
31+
"""
32+
return self.__enabled
33+
2734
@cached_property
2835
def log(self) -> LogTelemetryWriter:
2936
from dda.telemetry.writers.log import LogTelemetryWriter

src/dda/tools/base.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def env_vars(self) -> dict[str, str]: # noqa: PLR6301
4949
"""
5050
return {}
5151

52-
def run(self, command: list[str], *args: Any, **kwargs: Any) -> CompletedProcess:
52+
def run(self, command: list[str], **kwargs: Any) -> int:
5353
"""
5454
Equivalent to [`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run] with the `command` formatted
5555
by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment variables set
@@ -59,14 +59,13 @@ def run(self, command: list[str], *args: Any, **kwargs: Any) -> CompletedProcess
5959
command: The command to execute.
6060
6161
Other parameters:
62-
*args: Additional arguments to pass to [`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run].
6362
**kwargs: Additional keyword arguments to pass to
6463
[`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run].
6564
"""
6665
self.__populate_env_vars(kwargs)
67-
return self.app.subprocess.run(self.format_command(command), *args, **kwargs)
66+
return self.app.subprocess.run(self.format_command(command), **kwargs)
6867

69-
def capture(self, command: list[str], *args: Any, **kwargs: Any) -> str:
68+
def capture(self, command: list[str], **kwargs: Any) -> str:
7069
"""
7170
Equivalent to [`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture] with the `command`
7271
formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment
@@ -76,15 +75,13 @@ def capture(self, command: list[str], *args: Any, **kwargs: Any) -> str:
7675
command: The command to execute.
7776
7877
Other parameters:
79-
*args: Additional arguments to pass to
80-
[`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture].
8178
**kwargs: Additional keyword arguments to pass to
8279
[`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture].
8380
"""
8481
self.__populate_env_vars(kwargs)
85-
return self.app.subprocess.capture(self.format_command(command), *args, **kwargs)
82+
return self.app.subprocess.capture(self.format_command(command), **kwargs)
8683

87-
def wait(self, command: list[str], *args: Any, **kwargs: Any) -> None:
84+
def wait(self, command: list[str], **kwargs: Any) -> None:
8885
"""
8986
Equivalent to [`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait] with the `command` formatted
9087
by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment variables set
@@ -94,14 +91,13 @@ def wait(self, command: list[str], *args: Any, **kwargs: Any) -> None:
9491
command: The command to execute.
9592
9693
Other parameters:
97-
*args: Additional arguments to pass to [`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait].
9894
**kwargs: Additional keyword arguments to pass to
9995
[`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait].
10096
"""
10197
self.__populate_env_vars(kwargs)
102-
self.app.subprocess.wait(self.format_command(command), *args, **kwargs)
98+
self.app.subprocess.wait(self.format_command(command), **kwargs)
10399

104-
def exit_with(self, command: list[str], *args: Any, **kwargs: Any) -> NoReturn:
100+
def exit_with(self, command: list[str], **kwargs: Any) -> NoReturn:
105101
"""
106102
Equivalent to [`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with]
107103
with the `command` formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and
@@ -111,13 +107,27 @@ def exit_with(self, command: list[str], *args: Any, **kwargs: Any) -> NoReturn:
111107
command: The command to execute.
112108
113109
Other parameters:
114-
*args: Additional arguments to pass to
115-
[`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with].
116110
**kwargs: Additional keyword arguments to pass to
117111
[`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with].
118112
"""
119113
self.__populate_env_vars(kwargs)
120-
self.app.subprocess.exit_with(self.format_command(command), *args, **kwargs)
114+
self.app.subprocess.exit_with(self.format_command(command), **kwargs)
115+
116+
def attach(self, command: list[str], **kwargs: Any) -> CompletedProcess:
117+
"""
118+
Equivalent to [`SubprocessRunner.attach`][dda.utils.process.SubprocessRunner.attach] with the `command` formatted
119+
by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment variables set
120+
by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
121+
122+
Parameters:
123+
command: The command to execute.
124+
125+
Other parameters:
126+
**kwargs: Additional keyword arguments to pass to
127+
[`SubprocessRunner.attach`][dda.utils.process.SubprocessRunner.attach].
128+
"""
129+
self.__populate_env_vars(kwargs)
130+
return self.app.subprocess.attach(self.format_command(command), **kwargs)
121131

122132
def __populate_env_vars(self, kwargs: dict[str, Any]) -> None:
123133
env_vars = self.env_vars()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT

0 commit comments

Comments
 (0)