1#![forbid(unsafe_code)]
8
9#[doc(hidden)]
10pub mod auth;
11#[doc(hidden)]
12pub mod auth_namespace;
13#[doc(hidden)]
14pub mod doctor;
15mod error;
16#[doc(hidden)]
17pub mod flags;
18pub mod help;
19#[doc(hidden)]
20pub mod ids;
21#[doc(hidden)]
22pub mod investigate;
23mod native_debug_artifacts;
24mod parser;
25mod project_create;
26mod projects;
27#[doc(hidden)]
28pub mod render;
29#[doc(hidden)]
30pub mod setup;
31#[doc(hidden)]
32pub mod status;
33mod support;
34mod usage;
35#[doc(hidden)]
36pub mod version;
37
38use auth::{
39 AuthCredential, execute_login, execute_logout, send_authenticated_with_refresh,
40 token_is_project_ingest_key,
41};
42pub use error::{CliError, RuntimeError, write_cli_error, write_runtime_error};
43use futures_util::StreamExt as _;
44pub use parser::parse_command;
45use render::write_api_success;
46use setup::write_setup_plan;
47use status::execute_status;
48use tokio_tungstenite::connect_async;
49use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
50use version::execute_version;
51
52const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
54const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
56const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
58
59pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
61 "use one of unresolved/open, resolved/closed, ignored";
62pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
64 "use --status unresolved/open, --status resolved/closed, or --status ignored";
65pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
67 "provide one of unresolved/open, resolved/closed, ignored";
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Command {
72 Help {
74 topic: HelpTopic,
76 json: bool,
78 },
79 Login {
81 open_browser: bool,
83 json: bool,
85 },
86 Logout {
88 json: bool,
90 },
91 Setup {
93 auto: bool,
95 yes: bool,
97 json: bool,
99 },
100 Status {
102 json: bool,
104 },
105 Doctor {
107 project_id: String,
109 json: bool,
111 },
112 ProjectCreate {
114 options: ProjectCreateOptions,
116 json: bool,
118 },
119 Projects {
121 json: bool,
123 },
124 Usage {
126 json: bool,
128 },
129 Version {
131 json: bool,
133 },
134 Read {
136 target: ReadTarget,
138 options: Box<ReadOptions>,
140 json: bool,
142 },
143 Watch {
145 target: WatchTarget,
147 options: WatchOptions,
149 json: bool,
151 },
152 Explain {
154 target: ExplainTarget,
156 json: bool,
158 },
159 InvestigateIssue {
161 issue_id: String,
163 json: bool,
165 },
166 NativeDebugArtifacts {
168 target: NativeDebugArtifactsTarget,
170 json: bool,
172 },
173 Set {
175 target: SetTarget,
177 json: bool,
179 },
180 ProjectSetupSeen {
182 project_id: String,
184 options: ProjectSetupSeenOptions,
186 json: bool,
188 },
189 Support {
191 target: SupportTarget,
193 json: bool,
195 },
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum HelpTopic {
201 Root,
203 Login,
205 Logout,
207 Setup,
209 Status,
211 Version,
213 Auth,
215 Json,
217 Examples,
219 Projects,
221 Usage,
223 Read,
225 ReadLogs,
227 ReadIssues,
229 ReadActions,
231 ReadReleases,
233 ReadTraces,
235 ReadTrace,
237 ReadIssue,
239 Watch,
241 Explain,
243 Investigate,
245 NativeDebugArtifacts,
247 Set,
249 Support,
251}
252
253impl HelpTopic {
254 #[must_use]
256 pub const fn key(self) -> &'static str {
257 match self {
258 Self::Root => "root",
259 Self::Login => "login",
260 Self::Logout => "logout",
261 Self::Setup => "setup",
262 Self::Status => "status",
263 Self::Version => "version",
264 Self::Auth => "auth",
265 Self::Json => "json",
266 Self::Examples => "examples",
267 Self::Projects => "projects",
268 Self::Usage => "usage",
269 Self::Read => "read",
270 Self::ReadLogs => "read_logs",
271 Self::ReadIssues => "read_issues",
272 Self::ReadActions => "read_actions",
273 Self::ReadReleases => "read_releases",
274 Self::ReadTraces => "read_traces",
275 Self::ReadTrace => "read_trace",
276 Self::ReadIssue => "read_issue",
277 Self::Watch => "watch",
278 Self::Explain => "explain",
279 Self::Investigate => "investigate",
280 Self::NativeDebugArtifacts => "debug_artifacts",
281 Self::Set => "set",
282 Self::Support => "support",
283 }
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum ReadTarget {
290 Logs,
292 Issues,
294 Actions,
296 Releases,
298 Traces,
300 Trace(String),
302 Issue(String),
304}
305
306#[derive(Debug, Clone, Default, PartialEq, Eq)]
308pub struct ReadOptions {
309 pub name: Option<String>,
311 pub service: Option<String>,
313 pub since: Option<String>,
315 pub user: Option<String>,
317 pub trace: Option<String>,
319 pub level: Option<String>,
321 pub search: Option<String>,
323 pub project: Option<String>,
325 pub release: Option<String>,
327 pub environment: Option<String>,
329 pub status: Option<String>,
331 pub limit: Option<String>,
333 pub min_duration_ms: Option<String>,
335 pub pagination: Option<String>,
337 pub cursor_time: Option<String>,
339 pub cursor_id: Option<String>,
341}
342
343impl ReadOptions {
344 #[must_use]
346 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
347 first_present_flag([
348 (self.name.is_some(), "--name"),
349 (self.service.is_some(), "--service"),
350 (self.since.is_some(), "--since"),
351 (self.user.is_some(), "--user"),
352 (self.trace.is_some(), "--trace"),
353 (self.level.is_some(), "--severity"),
354 (self.search.is_some(), "--search"),
355 (self.status.is_some(), "--status"),
356 (self.limit.is_some(), "--limit"),
357 (self.min_duration_ms.is_some(), "--min-duration-ms"),
358 (self.pagination.is_some(), "--pagination"),
359 (self.cursor_time.is_some(), "--cursor-time"),
360 (self.cursor_id.is_some(), "--cursor-id"),
361 ])
362 }
363
364 #[must_use]
366 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
367 first_present_flag([
368 (self.name.is_some(), "--name"),
369 (self.service.is_some(), "--service"),
370 (self.since.is_some(), "--since"),
371 (self.user.is_some(), "--user"),
372 (self.trace.is_some(), "--trace"),
373 (self.level.is_some(), "--severity"),
374 (self.search.is_some(), "--search"),
375 (self.project.is_some(), "--project"),
376 (self.release.is_some(), "--release"),
377 (self.environment.is_some(), "--environment"),
378 (self.status.is_some(), "--status"),
379 (self.limit.is_some(), "--limit"),
380 (self.min_duration_ms.is_some(), "--min-duration-ms"),
381 (self.pagination.is_some(), "--pagination"),
382 (self.cursor_time.is_some(), "--cursor-time"),
383 (self.cursor_id.is_some(), "--cursor-id"),
384 ])
385 }
386
387 #[must_use]
389 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
390 first_present_flag([
391 (self.name.is_some(), "--name"),
392 (self.user.is_some(), "--user"),
393 (self.status.is_some(), "--status"),
394 (self.min_duration_ms.is_some(), "--min-duration-ms"),
395 ])
396 }
397
398 #[must_use]
400 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
401 first_present_flag([
402 (self.name.is_some(), "--name"),
403 (self.user.is_some(), "--user"),
404 (self.trace.is_some(), "--trace"),
405 (self.level.is_some(), "--severity"),
406 (self.search.is_some(), "--search"),
407 (self.min_duration_ms.is_some(), "--min-duration-ms"),
408 ])
409 }
410
411 #[must_use]
413 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
414 first_present_flag([
415 (self.trace.is_some(), "--trace"),
416 (self.level.is_some(), "--severity"),
417 (self.search.is_some(), "--search"),
418 (self.status.is_some(), "--status"),
419 (self.min_duration_ms.is_some(), "--min-duration-ms"),
420 ])
421 }
422
423 #[must_use]
425 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
426 first_present_flag([
427 (self.name.is_some(), "--name"),
428 (self.user.is_some(), "--user"),
429 (self.trace.is_some(), "--trace"),
430 (self.level.is_some(), "--severity"),
431 (self.search.is_some(), "--search"),
432 (self.status.is_some(), "--status"),
433 (self.min_duration_ms.is_some(), "--min-duration-ms"),
434 (self.pagination.is_some(), "--pagination"),
435 (self.cursor_time.is_some(), "--cursor-time"),
436 (self.cursor_id.is_some(), "--cursor-id"),
437 ])
438 }
439
440 #[must_use]
442 pub(crate) fn first_trace_list_unsupported_flag(&self) -> Option<&'static str> {
443 first_present_flag([
444 (self.name.is_some(), "--name"),
445 (self.user.is_some(), "--user"),
446 (self.trace.is_some(), "--trace"),
447 (self.level.is_some(), "--severity"),
448 (self.search.is_some(), "--search"),
449 (self.pagination.is_some(), "--pagination"),
450 (self.cursor_time.is_some(), "--cursor-time"),
451 (self.cursor_id.is_some(), "--cursor-id"),
452 ])
453 }
454}
455
456fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
458 flags
459 .iter()
460 .find_map(|(present, flag)| present.then_some(*flag))
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum WatchTarget {
466 All,
468 Logs,
470 Issues,
472 Actions,
474}
475
476#[derive(Debug, Clone, Default, PartialEq, Eq)]
478pub struct WatchOptions {
479 pub severity: Vec<String>,
481}
482
483#[derive(Debug, Clone, Default, PartialEq, Eq)]
485pub struct ProjectSetupSeenOptions {
486 pub runtime: Option<String>,
488 pub source: Option<String>,
490 pub environment: Option<String>,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct ProjectCreateOptions {
497 pub name: String,
499 pub runtime: Option<String>,
501 pub environment: Option<String>,
503 pub ingest_key_file: String,
505 pub abandon_retry: bool,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
511pub enum NativeDebugArtifactsTarget {
512 Upload(NativeDebugUploadOptions),
514 Lookup(NativeDebugLookupOptions),
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct NativeDebugLookupOptions {
521 pub project_id: String,
523 pub release: String,
525 pub environment: String,
527 pub service: String,
529 pub image_uuid: String,
531 pub architecture: String,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct NativeDebugUploadOptions {
538 pub path: String,
540 pub project_id: String,
542 pub release: String,
544 pub environment: String,
546 pub service: String,
548 pub expected_image_uuids: Vec<String>,
550 pub dry_run: bool,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
556pub enum SupportTarget {
557 Create(Box<SupportTicketCreateOptions>),
559 List(Box<SupportTicketListOptions>),
561 Detail(String),
563 ContextHistory {
565 ticket_id: String,
567 },
568 ReplyContext(Box<SupportContextReplyOptions>),
570 UpdateStatus {
572 ticket_id: String,
574 status: SupportTicketLifecycleStatus,
576 },
577}
578
579#[derive(Debug, Clone, Default, PartialEq, Eq)]
581pub struct SupportContextReplyOptions {
582 pub ticket_id: String,
584 pub context: String,
586 pub retry_key: String,
588 pub diagnostics: bool,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum SupportTicketLifecycleStatus {
595 Open,
597 Closed,
599}
600
601impl SupportTicketLifecycleStatus {
602 #[must_use]
604 pub const fn as_str(self) -> &'static str {
605 match self {
606 Self::Open => "open",
607 Self::Closed => "closed",
608 }
609 }
610}
611
612#[derive(Debug, Clone, Default, PartialEq, Eq)]
614pub struct SupportTicketCreateOptions {
615 pub category: String,
617 pub title: String,
619 pub description: String,
621 pub project_id: Option<String>,
623 pub environment: Option<String>,
625 pub runtime: Option<String>,
627 pub framework: Option<String>,
629 pub sdk_package: Option<String>,
631 pub sdk_version: Option<String>,
633 pub release: Option<String>,
635 pub trace_id: Option<String>,
637 pub event_id: Option<String>,
639 pub diagnostics: bool,
641}
642
643#[derive(Debug, Clone, Default, PartialEq, Eq)]
645pub struct SupportTicketListOptions {
646 pub project_id: Option<String>,
648 pub status: Option<String>,
650 pub source: Option<String>,
652 pub category: Option<String>,
654 pub release: Option<String>,
656 pub limit: Option<String>,
658 pub pagination: Option<String>,
660 pub cursor_time: Option<String>,
662 pub cursor_id: Option<String>,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum ExplainTarget {
669 Issue(String),
671 Trace(String),
673}
674
675#[derive(Debug, Clone, PartialEq, Eq)]
677pub enum SetTarget {
678 IssueStatus {
680 id: String,
682 status: String,
684 },
685}
686
687#[derive(Debug, Clone, PartialEq, Eq)]
689pub struct CliEnvironment {
690 pub base_url: String,
692 pub token: Option<String>,
694 pub home: Option<std::path::PathBuf>,
696 pub cwd: Option<std::path::PathBuf>,
698}
699
700impl CliEnvironment {
701 #[must_use]
703 pub fn from_process() -> Self {
704 Self {
705 base_url: std::env::var("LOGBREW_API_URL")
706 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
707 token: std::env::var("LOGBREW_TOKEN").ok(),
708 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
709 cwd: std::env::current_dir().ok(),
710 }
711 }
712}
713
714impl Command {
715 #[must_use]
717 pub fn http_path(&self) -> Option<String> {
718 match self {
719 Self::Read {
720 target, options, ..
721 } => Some(read_path(
722 target,
723 &ReadPathFilters {
724 name: options.name.as_deref(),
725 service: options.service.as_deref(),
726 since: options.since.as_deref(),
727 user: options.user.as_deref(),
728 trace: options.trace.as_deref(),
729 level: options.level.as_deref(),
730 search: options.search.as_deref(),
731 project: options.project.as_deref(),
732 release: options.release.as_deref(),
733 environment: options.environment.as_deref(),
734 status: options.status.as_deref(),
735 limit: options.limit.as_deref(),
736 min_duration_ms: options.min_duration_ms.as_deref(),
737 pagination: options.pagination.as_deref(),
738 cursor_time: options.cursor_time.as_deref(),
739 cursor_id: options.cursor_id.as_deref(),
740 },
741 )),
742 Self::Explain { target, .. } => Some(explain_path(target)),
743 Self::Set { target, .. } => Some(set_path(target)),
744 Self::ProjectSetupSeen { project_id, .. } => {
745 Some(format!("/api/projects/{project_id}/setup/seen"))
746 }
747 Self::ProjectCreate { .. } | Self::Projects { .. } => {
748 Some(String::from("/api/projects"))
749 }
750 Self::Support { target, .. } => Some(support::path(target)),
751 Self::Help { .. }
752 | Self::Login { .. }
753 | Self::Logout { .. }
754 | Self::Setup { .. }
755 | Self::Status { .. }
756 | Self::Doctor { .. }
757 | Self::Usage { .. }
758 | Self::Version { .. }
759 | Self::InvestigateIssue { .. }
760 | Self::NativeDebugArtifacts { .. }
761 | Self::Watch { .. } => None,
762 }
763 }
764
765 #[must_use]
767 pub const fn wants_json(&self) -> bool {
768 match self {
769 Self::Help { json, .. }
770 | Self::Login { json, .. }
771 | Self::Logout { json }
772 | Self::Status { json }
773 | Self::Doctor { json, .. }
774 | Self::ProjectCreate { json, .. }
775 | Self::Projects { json }
776 | Self::Usage { json }
777 | Self::Version { json }
778 | Self::Read { json, .. }
779 | Self::Watch { json, .. }
780 | Self::Explain { json, .. }
781 | Self::InvestigateIssue { json, .. }
782 | Self::NativeDebugArtifacts { json, .. }
783 | Self::Set { json, .. }
784 | Self::ProjectSetupSeen { json, .. }
785 | Self::Support { json, .. }
786 | Self::Setup { json, .. } => *json,
787 }
788 }
789
790 #[must_use]
792 pub const fn http_method(&self) -> Option<HttpMethod> {
793 match self {
794 Self::ProjectCreate { .. }
795 | Self::ProjectSetupSeen { .. }
796 | Self::Support {
797 target: SupportTarget::Create(_),
798 ..
799 }
800 | Self::Support {
801 target: SupportTarget::ReplyContext(_),
802 ..
803 } => Some(HttpMethod::Post),
804 Self::Support {
805 target: SupportTarget::UpdateStatus { .. },
806 ..
807 }
808 | Self::Set { .. } => Some(HttpMethod::Patch),
809 Self::Projects { .. }
810 | Self::Read { .. }
811 | Self::Explain { .. }
812 | Self::Support { .. } => Some(HttpMethod::Get),
813 Self::Help { .. }
814 | Self::Login { .. }
815 | Self::Logout { .. }
816 | Self::Setup { .. }
817 | Self::Status { .. }
818 | Self::Doctor { .. }
819 | Self::Usage { .. }
820 | Self::Version { .. }
821 | Self::InvestigateIssue { .. }
822 | Self::NativeDebugArtifacts { .. }
823 | Self::Watch { .. } => None,
824 }
825 }
826
827 #[must_use]
829 pub fn request_body(&self) -> Option<serde_json::Value> {
830 self.request_body_for_token(None)
831 }
832
833 #[must_use]
835 fn request_body_for_token(&self, token: Option<&str>) -> Option<serde_json::Value> {
836 match self {
837 Self::Set {
838 target: SetTarget::IssueStatus { status, .. },
839 ..
840 } => Some(serde_json::json!({ "status": status })),
841 Self::ProjectSetupSeen { options, .. } => Some(project_setup_seen_body(options, token)),
842 Self::ProjectCreate { options, .. } => Some(project_create_body(options)),
843 Self::Support {
844 target: SupportTarget::Create(options),
845 ..
846 } => Some(support::create_body(options)),
847 Self::Support {
848 target: SupportTarget::ReplyContext(options),
849 ..
850 } => Some(support::context_body(options)),
851 Self::Support {
852 target: SupportTarget::UpdateStatus { status, .. },
853 ..
854 } => Some(serde_json::json!({"status": status.as_str()})),
855 Self::Help { .. }
856 | Self::Login { .. }
857 | Self::Logout { .. }
858 | Self::Setup { .. }
859 | Self::Status { .. }
860 | Self::Doctor { .. }
861 | Self::Projects { .. }
862 | Self::Usage { .. }
863 | Self::Version { .. }
864 | Self::Read { .. }
865 | Self::Watch { .. }
866 | Self::Explain { .. }
867 | Self::InvestigateIssue { .. }
868 | Self::NativeDebugArtifacts { .. }
869 | Self::Support { .. } => None,
870 }
871 }
872
873 fn idempotency_key(&self) -> Option<&str> {
875 match self {
876 Self::Support {
877 target: SupportTarget::ReplyContext(options),
878 ..
879 } => Some(options.retry_key.as_str()),
880 Self::Help { .. }
881 | Self::Login { .. }
882 | Self::Logout { .. }
883 | Self::Setup { .. }
884 | Self::Status { .. }
885 | Self::Doctor { .. }
886 | Self::Usage { .. }
887 | Self::Version { .. }
888 | Self::Read { .. }
889 | Self::Watch { .. }
890 | Self::Explain { .. }
891 | Self::InvestigateIssue { .. }
892 | Self::NativeDebugArtifacts { .. }
893 | Self::Set { .. }
894 | Self::ProjectSetupSeen { .. }
895 | Self::ProjectCreate { .. }
896 | Self::Projects { .. }
897 | Self::Support { .. } => None,
898 }
899 }
900}
901
902#[derive(Debug, Clone, Copy, PartialEq, Eq)]
904pub enum HttpMethod {
905 Get,
907 Post,
909 Patch,
911}
912
913fn project_setup_seen_body(
915 options: &ProjectSetupSeenOptions,
916 token: Option<&str>,
917) -> serde_json::Value {
918 let mut body = serde_json::Map::new();
919 if let Some(runtime) = options.runtime.as_ref() {
920 drop(body.insert(
921 "runtime".to_owned(),
922 serde_json::Value::String(runtime.clone()),
923 ));
924 }
925 if let Some(source) = setup_seen_source(options, token) {
926 drop(body.insert("source".to_owned(), serde_json::Value::String(source)));
927 }
928 if let Some(environment) = options.environment.as_ref() {
929 drop(body.insert(
930 "environment".to_owned(),
931 serde_json::Value::String(environment.clone()),
932 ));
933 }
934 serde_json::Value::Object(body)
935}
936
937fn project_create_body(options: &ProjectCreateOptions) -> serde_json::Value {
939 let mut body = serde_json::Map::new();
940 drop(body.insert(
941 "name".to_owned(),
942 serde_json::Value::String(options.name.clone()),
943 ));
944 if let Some(runtime) = options.runtime.as_ref() {
945 drop(body.insert(
946 "runtime".to_owned(),
947 serde_json::Value::String(runtime.clone()),
948 ));
949 }
950 if let Some(environment) = options.environment.as_ref() {
951 drop(body.insert(
952 "environment".to_owned(),
953 serde_json::Value::String(environment.clone()),
954 ));
955 }
956 drop(body.insert(
957 "source".to_owned(),
958 serde_json::Value::String(String::from("cli")),
959 ));
960 serde_json::Value::Object(body)
961}
962
963fn setup_seen_source(options: &ProjectSetupSeenOptions, token: Option<&str>) -> Option<String> {
965 if token_is_project_ingest_key(token) {
966 return None;
967 }
968 Some(options.source.as_deref().unwrap_or("cli").to_owned())
969}
970
971pub async fn execute_command<W: std::io::Write>(
977 command: &Command,
978 env: &CliEnvironment,
979 output: &mut W,
980) -> Result<(), RuntimeError> {
981 match command {
982 Command::Help { topic, json } => execute_help(*topic, *json, output),
983 Command::Login { open_browser, json } => {
984 execute_login(env, *open_browser, *json, output).await
985 }
986 Command::Logout { json } => execute_logout(env, *json, output).await,
987 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
988 Command::Status { json } => execute_status(env, *json, output).await,
989 Command::Doctor { project_id, json } => {
990 doctor::execute(env, project_id.as_str(), *json, output).await
991 }
992 Command::ProjectCreate { options, json } => {
993 project_create::execute(env, options, *json, output).await
994 }
995 Command::Projects { json } => projects::execute(env, *json, output).await,
996 Command::Usage { json } => usage::execute(env, *json, output).await,
997 Command::Version { json } => execute_version(*json, output),
998 Command::InvestigateIssue { issue_id, json } => {
999 investigate::execute(env, issue_id.as_str(), *json, output).await
1000 }
1001 Command::NativeDebugArtifacts { target, json } => {
1002 native_debug_artifacts::execute(env, target, *json, output).await
1003 }
1004 Command::Read { .. }
1005 | Command::Explain { .. }
1006 | Command::Set { .. }
1007 | Command::ProjectSetupSeen { .. }
1008 | Command::Support { .. } => execute_http(command, env, output).await,
1009 Command::Watch {
1010 target,
1011 options,
1012 json,
1013 } => execute_watch(env, *target, options, *json, output).await,
1014 }
1015}
1016
1017fn execute_help<W: std::io::Write>(
1019 topic: HelpTopic,
1020 json: bool,
1021 output: &mut W,
1022) -> Result<(), RuntimeError> {
1023 let help = help::help_text(topic);
1024 if json {
1025 let body = serde_json::json!({
1026 "ok": true,
1027 "topic": topic.key(),
1028 "help": help,
1029 });
1030 writeln!(output, "{body}")?;
1031 } else {
1032 writeln!(output, "{help}")?;
1033 }
1034 Ok(())
1035}
1036
1037fn execute_setup<W: std::io::Write>(
1039 env: &CliEnvironment,
1040 auto: bool,
1041 yes: bool,
1042 json: bool,
1043 output: &mut W,
1044) -> Result<(), RuntimeError> {
1045 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
1046 Ok(())
1047}
1048
1049async fn execute_http<W: std::io::Write>(
1051 command: &Command,
1052 env: &CliEnvironment,
1053 output: &mut W,
1054) -> Result<(), RuntimeError> {
1055 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
1056 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
1057 let client = reqwest::Client::builder()
1058 .timeout(std::time::Duration::from_secs(30))
1059 .connect_timeout(std::time::Duration::from_secs(10))
1060 .build()?;
1061
1062 let support_command = matches!(command, Command::Support { .. });
1063 let response_result = send_authenticated_with_refresh(&client, env, |client, credential| {
1064 build_command_request(client, command, url.as_str(), credential)
1065 })
1066 .await;
1067 let (response, credential) = match response_result {
1068 Ok(response) => response,
1069 Err(RuntimeError::Http(_)) if support_command => return Err(support_transport_error()),
1070 Err(error) => return Err(error),
1071 };
1072 let status = response.status();
1073 let body = match response.text().await {
1074 Ok(body) => body,
1075 Err(_) if support_command => return Err(support_transport_error()),
1076 Err(error) => return Err(RuntimeError::Http(error)),
1077 };
1078
1079 if !status.is_success() {
1080 let body = if let Command::Support { target, .. } = command {
1081 support::safe_error_body(target, status.as_u16())
1082 } else {
1083 credential.redact_response_body(body.as_str())
1084 };
1085 return Err(RuntimeError::Api {
1086 status: status.as_u16(),
1087 body,
1088 auth_source: credential.source(),
1089 auth_label: credential.label(),
1090 });
1091 }
1092
1093 write_api_success(command, body.as_str(), output)?;
1094 Ok(())
1095}
1096
1097const fn support_transport_error() -> RuntimeError {
1099 RuntimeError::Unavailable {
1100 message: "support request could not be completed",
1101 next: "check network connectivity and retry the support command",
1102 }
1103}
1104
1105fn build_command_request(
1107 client: &reqwest::Client,
1108 command: &Command,
1109 url: &str,
1110 credential: &AuthCredential,
1111) -> reqwest::RequestBuilder {
1112 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
1113 HttpMethod::Get => client.get(url),
1114 HttpMethod::Post => client.post(url),
1115 HttpMethod::Patch => client.patch(url),
1116 }
1117 .bearer_auth(credential.token());
1118 if let Some(body) = command.request_body_for_token(Some(credential.token())) {
1119 request = request.json(&body);
1120 }
1121 if let Some(key) = command.idempotency_key() {
1122 request = request.header("Idempotency-Key", key);
1123 }
1124 request
1125}
1126
1127async fn execute_watch<W: std::io::Write>(
1129 env: &CliEnvironment,
1130 target: WatchTarget,
1131 options: &WatchOptions,
1132 json: bool,
1133 output: &mut W,
1134) -> Result<(), RuntimeError> {
1135 if !json {
1136 return Err(RuntimeError::Unavailable {
1137 message: "watch streams JSON for agents",
1138 next: "run logbrew watch --json",
1139 });
1140 }
1141
1142 let mut reconnect_backoff = WatchReconnectBackoff::default();
1143 loop {
1144 let ticket = match request_feed_ticket(env).await {
1145 Ok(ticket) => ticket,
1146 Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
1147 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1148 continue;
1149 }
1150 Err(error) => return Err(error),
1151 };
1152 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
1153 let (mut websocket, _) = match connect_async(live_url.as_str()).await {
1154 Ok(connection) => connection,
1155 Err(error)
1156 if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
1157 {
1158 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1159 continue;
1160 }
1161 Err(error) => return Err(map_websocket_connect_error(error)),
1162 };
1163 reconnect_backoff.mark_connected();
1164
1165 let mut emitted_before_disconnect = false;
1166 loop {
1167 let Some(message) = websocket.next().await else {
1168 break;
1169 };
1170 let message = match message {
1171 Ok(message) => message,
1172 Err(error) if websocket_error_is_auth(&error) => {
1173 return Err(map_websocket_stream_error(error));
1174 }
1175 Err(_) => break,
1176 };
1177 match message {
1178 Message::Text(text) => {
1179 let event = parse_live_event(text.as_str())?;
1180 if watch_event_matches(target, options, &event) {
1181 writeln!(output, "{event}")?;
1182 }
1183 emitted_before_disconnect = true;
1184 }
1185 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
1186 Message::Close(_) => return Ok(()),
1187 }
1188 }
1189 if emitted_before_disconnect {
1190 reconnect_backoff.reset();
1191 }
1192 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1193 }
1194}
1195
1196#[derive(Debug, Default)]
1198struct WatchReconnectBackoff {
1199 connected_once: bool,
1201 attempts: u32,
1203}
1204
1205impl WatchReconnectBackoff {
1206 const fn connected_once(&self) -> bool {
1208 self.connected_once
1209 }
1210
1211 const fn mark_connected(&mut self) {
1213 self.connected_once = true;
1214 }
1215
1216 const fn reset(&mut self) {
1218 self.attempts = 0;
1219 }
1220
1221 fn next_delay(&mut self) -> std::time::Duration {
1223 let exponent = self.attempts.min(5);
1224 let multiplier = 1_u64 << exponent;
1225 self.attempts = self.attempts.saturating_add(1);
1226 let base = WATCH_RECONNECT_INITIAL_DELAY
1227 .as_secs()
1228 .saturating_mul(multiplier)
1229 .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
1230 let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
1231 if delay > WATCH_RECONNECT_MAX_DELAY {
1232 WATCH_RECONNECT_MAX_DELAY
1233 } else {
1234 delay
1235 }
1236 }
1237}
1238
1239fn watch_reconnect_jitter() -> std::time::Duration {
1241 let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
1242 return std::time::Duration::ZERO;
1243 };
1244 std::time::Duration::from_millis(
1245 u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
1246 )
1247}
1248
1249const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
1251 matches!(
1252 error,
1253 RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
1254 )
1255}
1256
1257fn websocket_error_is_auth(error: &WebSocketError) -> bool {
1259 matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
1260}
1261
1262async fn request_feed_ticket(env: &CliEnvironment) -> Result<String, RuntimeError> {
1264 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
1265 let client = reqwest::Client::builder()
1266 .timeout(std::time::Duration::from_secs(30))
1267 .connect_timeout(std::time::Duration::from_secs(10))
1268 .build()?;
1269 let (response, credential) =
1270 send_authenticated_with_refresh(&client, env, |client, credential| {
1271 client.post(url.as_str()).bearer_auth(credential.token())
1272 })
1273 .await?;
1274 let status = response.status();
1275 let body = response.text().await?;
1276 if !status.is_success() {
1277 return Err(RuntimeError::Api {
1278 status: status.as_u16(),
1279 body: credential.redact_response_body(body.as_str()),
1280 auth_source: credential.source(),
1281 auth_label: credential.label(),
1282 });
1283 }
1284
1285 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
1286 RuntimeError::Unavailable {
1287 message: "feed ticket response was not valid JSON",
1288 next: "retry logbrew watch or run logbrew status",
1289 }
1290 })?;
1291 value
1292 .get("ticket")
1293 .and_then(serde_json::Value::as_str)
1294 .map(str::trim)
1295 .filter(|ticket| !ticket.is_empty())
1296 .map(ToOwned::to_owned)
1297 .ok_or(RuntimeError::Unavailable {
1298 message: "feed ticket response did not include a ticket",
1299 next: "retry logbrew watch or run logbrew status",
1300 })
1301}
1302
1303fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
1305 let trimmed = base_url.trim_end_matches('/');
1306 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
1307 message: "LOGBREW_API_URL must start with http:// or https://",
1308 next: "check LOGBREW_API_URL or run logbrew status",
1309 })?;
1310 Ok(format!(
1311 "{scheme}://{rest}/api/feed/live?ticket={}",
1312 encode_component(ticket)
1313 ))
1314}
1315
1316fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
1318 base_url
1319 .strip_prefix("https://")
1320 .map(|rest| ("wss", rest))
1321 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
1322}
1323
1324fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
1326 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
1327 message: "live watch event was not valid JSON",
1328 next: "retry logbrew watch or check LOGBREW_API_URL",
1329 })
1330}
1331
1332fn watch_event_matches(
1334 target: WatchTarget,
1335 options: &WatchOptions,
1336 event: &serde_json::Value,
1337) -> bool {
1338 target_matches_event(target, event) && severity_matches(options, event)
1339}
1340
1341fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
1343 let event_type = event
1344 .get("type")
1345 .and_then(serde_json::Value::as_str)
1346 .unwrap_or_default();
1347 match target {
1348 WatchTarget::All => true,
1349 WatchTarget::Logs => event_type == "native_log",
1350 WatchTarget::Issues => event_type == "native_issue",
1351 WatchTarget::Actions => event_type == "native_action",
1352 }
1353}
1354
1355fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
1357 if options.severity.is_empty() {
1358 return true;
1359 }
1360 let Some(severity) = event
1361 .get("data")
1362 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
1363 .and_then(serde_json::Value::as_str)
1364 else {
1365 return false;
1366 };
1367 options
1368 .severity
1369 .iter()
1370 .any(|allowed| allowed.as_str() == severity)
1371}
1372
1373fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
1375 match error {
1376 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1377 RuntimeError::Unavailable {
1378 message: "live watch ticket was rejected",
1379 next: "run logbrew login",
1380 }
1381 }
1382 WebSocketError::Http(_) => RuntimeError::Unavailable {
1383 message: "live watch websocket upgrade failed",
1384 next: "retry logbrew watch or check LOGBREW_API_URL",
1385 },
1386 WebSocketError::ConnectionClosed
1387 | WebSocketError::AlreadyClosed
1388 | WebSocketError::Io(_)
1389 | WebSocketError::Tls(_)
1390 | WebSocketError::Capacity(_)
1391 | WebSocketError::Protocol(_)
1392 | WebSocketError::WriteBufferFull(_)
1393 | WebSocketError::Utf8(_)
1394 | WebSocketError::AttackAttempt
1395 | WebSocketError::Url(_)
1396 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1397 message: "live watch websocket failed",
1398 next: "retry logbrew watch or check LOGBREW_API_URL",
1399 },
1400 }
1401}
1402
1403fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
1405 match error {
1406 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
1407 RuntimeError::Unavailable {
1408 message: "live watch websocket closed",
1409 next: "retry logbrew watch",
1410 }
1411 }
1412 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1413 RuntimeError::Unavailable {
1414 message: "live watch ticket was rejected",
1415 next: "run logbrew login",
1416 }
1417 }
1418 WebSocketError::Http(_)
1419 | WebSocketError::Io(_)
1420 | WebSocketError::Tls(_)
1421 | WebSocketError::Capacity(_)
1422 | WebSocketError::Protocol(_)
1423 | WebSocketError::WriteBufferFull(_)
1424 | WebSocketError::Utf8(_)
1425 | WebSocketError::AttackAttempt
1426 | WebSocketError::Url(_)
1427 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1428 message: "live watch websocket failed",
1429 next: "retry logbrew watch or check LOGBREW_API_URL",
1430 },
1431 }
1432}
1433
1434struct ReadPathFilters<'a> {
1436 name: Option<&'a str>,
1438 service: Option<&'a str>,
1440 since: Option<&'a str>,
1442 user: Option<&'a str>,
1444 trace: Option<&'a str>,
1446 level: Option<&'a str>,
1448 search: Option<&'a str>,
1450 project: Option<&'a str>,
1452 release: Option<&'a str>,
1454 environment: Option<&'a str>,
1456 status: Option<&'a str>,
1458 limit: Option<&'a str>,
1460 min_duration_ms: Option<&'a str>,
1462 pagination: Option<&'a str>,
1464 cursor_time: Option<&'a str>,
1466 cursor_id: Option<&'a str>,
1468}
1469
1470fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
1472 match target {
1473 ReadTarget::Logs => path_with_query(
1474 "/api/logs",
1475 &[
1476 ("service_name", filters.service),
1477 ("severity", filters.level),
1478 ("search", filters.search),
1479 ("since", filters.since),
1480 ("trace_id", filters.trace),
1481 ("project_id", filters.project),
1482 ("release", filters.release),
1483 ("environment", filters.environment),
1484 ("pagination", filters.pagination),
1485 ("cursor_time", filters.cursor_time),
1486 ("cursor_id", filters.cursor_id),
1487 ("limit", filters.limit),
1488 ],
1489 ),
1490 ReadTarget::Issues => path_with_query(
1491 "/api/telemetry/issues",
1492 &[
1493 ("service_name", filters.service),
1494 ("since", filters.since),
1495 ("status", filters.status),
1496 ("project_id", filters.project),
1497 ("release", filters.release),
1498 ("environment", filters.environment),
1499 ("pagination", filters.pagination),
1500 ("cursor_time", filters.cursor_time),
1501 ("cursor_id", filters.cursor_id),
1502 ("limit", filters.limit),
1503 ],
1504 ),
1505 ReadTarget::Actions => path_with_query(
1506 "/api/telemetry/actions",
1507 &[
1508 ("service_name", filters.service),
1509 ("name", filters.name),
1510 ("since", filters.since),
1511 ("distinct_id", filters.user),
1512 ("project_id", filters.project),
1513 ("release", filters.release),
1514 ("environment", filters.environment),
1515 ("pagination", filters.pagination),
1516 ("cursor_time", filters.cursor_time),
1517 ("cursor_id", filters.cursor_id),
1518 ("limit", filters.limit),
1519 ],
1520 ),
1521 ReadTarget::Releases => path_with_query(
1522 "/api/telemetry/releases",
1523 &[
1524 ("service_name", filters.service),
1525 ("since", filters.since),
1526 ("project_id", filters.project),
1527 ("release", filters.release),
1528 ("environment", filters.environment),
1529 ("limit", filters.limit),
1530 ],
1531 ),
1532 ReadTarget::Traces => path_with_query(
1533 "/api/telemetry/traces",
1534 &[
1535 ("project_id", filters.project),
1536 ("service_name", filters.service),
1537 ("release", filters.release),
1538 ("environment", filters.environment),
1539 ("status", filters.status),
1540 ("since", filters.since),
1541 ("min_duration_ms", filters.min_duration_ms),
1542 ("limit", filters.limit),
1543 ],
1544 ),
1545 ReadTarget::Trace(id) => path_with_query(
1546 &format!("/api/telemetry/traces/{}", encode_component(id)),
1547 &[
1548 ("project_id", filters.project),
1549 ("release", filters.release),
1550 ("environment", filters.environment),
1551 ],
1552 ),
1553 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1554 }
1555}
1556
1557fn explain_path(target: &ExplainTarget) -> String {
1559 match target {
1560 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1561 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1562 }
1563}
1564
1565fn set_path(target: &SetTarget) -> String {
1567 match target {
1568 SetTarget::IssueStatus { id, .. } => {
1569 format!("/api/telemetry/issues/{}", encode_component(id))
1570 }
1571 }
1572}
1573
1574fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1576 let query = params
1577 .iter()
1578 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1579 .collect::<Vec<_>>();
1580
1581 if query.is_empty() {
1582 path.to_owned()
1583 } else {
1584 format!("{path}?{}", query.join("&"))
1585 }
1586}
1587
1588fn encode_component(value: &str) -> String {
1590 let mut encoded = String::new();
1591 for byte in value.bytes() {
1592 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1593 encoded.push(char::from(byte));
1594 } else {
1595 encoded.push('%');
1596 encoded.push(hex_digit(byte >> 4));
1597 encoded.push(hex_digit(byte & 0x0f));
1598 }
1599 }
1600 encoded
1601}
1602
1603fn hex_digit(nibble: u8) -> char {
1605 match nibble {
1606 0..=9 => char::from(b'0' + nibble),
1607 10..=15 => char::from(b'A' + (nibble - 10)),
1608 _ => '?',
1609 }
1610}