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_archive;
26mod project_create;
27mod projects;
28#[doc(hidden)]
29pub mod render;
30#[doc(hidden)]
31pub mod setup;
32#[doc(hidden)]
33pub mod status;
34mod support;
35mod usage;
36#[doc(hidden)]
37pub mod version;
38
39use auth::{
40 AuthCredential, execute_login, execute_logout, execute_whoami, send_authenticated_with_refresh,
41 token_is_project_ingest_key,
42};
43pub use error::{
44 CliError, RuntimeError, write_cli_error, write_native_debug_runtime_error, write_runtime_error,
45};
46use futures_util::StreamExt as _;
47pub use parser::parse_command;
48use render::write_api_success;
49use setup::write_setup_plan;
50use status::execute_status;
51use tokio_tungstenite::connect_async;
52use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
53use version::execute_version;
54
55const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
57const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
59const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
61
62pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
64 "use one of unresolved/open, resolved/closed, ignored";
65pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
67 "use --status unresolved/open, --status resolved/closed, or --status ignored";
68pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
70 "provide one of unresolved/open, resolved/closed, ignored";
71
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
74pub enum LoginProvider {
75 #[default]
77 GitHub,
78 GitLab,
80 Bitbucket,
82}
83
84impl LoginProvider {
85 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::GitHub => "github",
90 Self::GitLab => "gitlab",
91 Self::Bitbucket => "bitbucket",
92 }
93 }
94}
95
96impl std::str::FromStr for LoginProvider {
97 type Err = CliError;
98
99 fn from_str(value: &str) -> Result<Self, Self::Err> {
100 match value {
101 "github" => Ok(Self::GitHub),
102 "gitlab" => Ok(Self::GitLab),
103 "bitbucket" => Ok(Self::Bitbucket),
104 _ => Err(CliError::InvalidLoginProvider),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum Command {
112 Help {
114 topic: HelpTopic,
116 json: bool,
118 },
119 Login {
121 provider: LoginProvider,
123 open_browser: bool,
125 json: bool,
127 },
128 Logout {
130 json: bool,
132 },
133 Setup {
135 auto: bool,
137 yes: bool,
139 json: bool,
141 },
142 Status {
144 json: bool,
146 },
147 WhoAmI {
149 json: bool,
151 },
152 Doctor {
154 project_id: String,
156 json: bool,
158 },
159 ProjectCreate {
161 options: ProjectCreateOptions,
163 json: bool,
165 },
166 ProjectIngestKeyCreate {
168 options: ProjectIngestKeyCreateOptions,
170 json: bool,
172 },
173 ProjectArchive {
175 project_id: String,
177 json: bool,
179 },
180 Projects {
182 json: bool,
184 },
185 Usage {
187 json: bool,
189 },
190 Version {
192 json: bool,
194 },
195 Read {
197 target: ReadTarget,
199 options: Box<ReadOptions>,
201 json: bool,
203 },
204 Watch {
206 target: WatchTarget,
208 options: WatchOptions,
210 json: bool,
212 },
213 Explain {
215 target: ExplainTarget,
217 json: bool,
219 },
220 InvestigateIssue {
222 issue_id: String,
224 json: bool,
226 },
227 NativeDebugArtifacts {
229 target: NativeDebugArtifactsTarget,
231 json: bool,
233 },
234 Set {
236 target: SetTarget,
238 json: bool,
240 },
241 ProjectSetupSeen {
243 project_id: String,
245 options: ProjectSetupSeenOptions,
247 json: bool,
249 },
250 Support {
252 target: SupportTarget,
254 json: bool,
256 },
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum HelpTopic {
262 Root,
264 Login,
266 Logout,
268 Setup,
270 Status,
272 Version,
274 Auth,
276 Json,
278 Examples,
280 Projects,
282 Usage,
284 Read,
286 ReadLogs,
288 ReadIssues,
290 ReadActions,
292 ReadReleases,
294 ReadTraces,
296 ReadTrace,
298 ReadIssue,
300 Watch,
302 Explain,
304 Investigate,
306 NativeDebugArtifacts,
308 Set,
310 Support,
312}
313
314impl HelpTopic {
315 #[must_use]
317 pub const fn key(self) -> &'static str {
318 match self {
319 Self::Root => "root",
320 Self::Login => "login",
321 Self::Logout => "logout",
322 Self::Setup => "setup",
323 Self::Status => "status",
324 Self::Version => "version",
325 Self::Auth => "auth",
326 Self::Json => "json",
327 Self::Examples => "examples",
328 Self::Projects => "projects",
329 Self::Usage => "usage",
330 Self::Read => "read",
331 Self::ReadLogs => "read_logs",
332 Self::ReadIssues => "read_issues",
333 Self::ReadActions => "read_actions",
334 Self::ReadReleases => "read_releases",
335 Self::ReadTraces => "read_traces",
336 Self::ReadTrace => "read_trace",
337 Self::ReadIssue => "read_issue",
338 Self::Watch => "watch",
339 Self::Explain => "explain",
340 Self::Investigate => "investigate",
341 Self::NativeDebugArtifacts => "debug_artifacts",
342 Self::Set => "set",
343 Self::Support => "support",
344 }
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
350pub enum ReadTarget {
351 Logs,
353 Issues,
355 Actions,
357 Releases,
359 Traces,
361 Trace(String),
363 Issue(String),
365}
366
367#[derive(Debug, Clone, Default, PartialEq, Eq)]
369pub struct ReadOptions {
370 pub name: Option<String>,
372 pub service: Option<String>,
374 pub since: Option<String>,
376 pub user: Option<String>,
378 pub trace: Option<String>,
380 pub level: Option<String>,
382 pub search: Option<String>,
384 pub project: Option<String>,
386 pub release: Option<String>,
388 pub environment: Option<String>,
390 pub status: Option<String>,
392 pub limit: Option<String>,
394 pub min_duration_ms: Option<String>,
396 pub pagination: Option<String>,
398 pub cursor_time: Option<String>,
400 pub cursor_id: Option<String>,
402}
403
404impl ReadOptions {
405 #[must_use]
407 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
408 first_present_flag([
409 (self.name.is_some(), "--name"),
410 (self.service.is_some(), "--service"),
411 (self.since.is_some(), "--since"),
412 (self.user.is_some(), "--user"),
413 (self.trace.is_some(), "--trace"),
414 (self.level.is_some(), "--severity"),
415 (self.search.is_some(), "--search"),
416 (self.status.is_some(), "--status"),
417 (self.limit.is_some(), "--limit"),
418 (self.min_duration_ms.is_some(), "--min-duration-ms"),
419 (self.pagination.is_some(), "--pagination"),
420 (self.cursor_time.is_some(), "--cursor-time"),
421 (self.cursor_id.is_some(), "--cursor-id"),
422 ])
423 }
424
425 #[must_use]
427 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
428 first_present_flag([
429 (self.name.is_some(), "--name"),
430 (self.service.is_some(), "--service"),
431 (self.since.is_some(), "--since"),
432 (self.user.is_some(), "--user"),
433 (self.trace.is_some(), "--trace"),
434 (self.level.is_some(), "--severity"),
435 (self.search.is_some(), "--search"),
436 (self.project.is_some(), "--project"),
437 (self.release.is_some(), "--release"),
438 (self.environment.is_some(), "--environment"),
439 (self.status.is_some(), "--status"),
440 (self.limit.is_some(), "--limit"),
441 (self.min_duration_ms.is_some(), "--min-duration-ms"),
442 (self.pagination.is_some(), "--pagination"),
443 (self.cursor_time.is_some(), "--cursor-time"),
444 (self.cursor_id.is_some(), "--cursor-id"),
445 ])
446 }
447
448 #[must_use]
450 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
451 first_present_flag([
452 (self.name.is_some(), "--name"),
453 (self.user.is_some(), "--user"),
454 (self.status.is_some(), "--status"),
455 (self.min_duration_ms.is_some(), "--min-duration-ms"),
456 ])
457 }
458
459 #[must_use]
461 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
462 first_present_flag([
463 (self.name.is_some(), "--name"),
464 (self.user.is_some(), "--user"),
465 (self.trace.is_some(), "--trace"),
466 (self.level.is_some(), "--severity"),
467 (self.search.is_some(), "--search"),
468 (self.min_duration_ms.is_some(), "--min-duration-ms"),
469 ])
470 }
471
472 #[must_use]
474 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
475 first_present_flag([
476 (self.trace.is_some(), "--trace"),
477 (self.level.is_some(), "--severity"),
478 (self.search.is_some(), "--search"),
479 (self.status.is_some(), "--status"),
480 (self.min_duration_ms.is_some(), "--min-duration-ms"),
481 ])
482 }
483
484 #[must_use]
486 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
487 first_present_flag([
488 (self.name.is_some(), "--name"),
489 (self.user.is_some(), "--user"),
490 (self.trace.is_some(), "--trace"),
491 (self.level.is_some(), "--severity"),
492 (self.search.is_some(), "--search"),
493 (self.status.is_some(), "--status"),
494 (self.min_duration_ms.is_some(), "--min-duration-ms"),
495 (self.pagination.is_some(), "--pagination"),
496 (self.cursor_time.is_some(), "--cursor-time"),
497 (self.cursor_id.is_some(), "--cursor-id"),
498 ])
499 }
500
501 #[must_use]
503 pub(crate) fn first_trace_list_unsupported_flag(&self) -> Option<&'static str> {
504 first_present_flag([
505 (self.name.is_some(), "--name"),
506 (self.user.is_some(), "--user"),
507 (self.trace.is_some(), "--trace"),
508 (self.level.is_some(), "--severity"),
509 (self.search.is_some(), "--search"),
510 (self.pagination.is_some(), "--pagination"),
511 (self.cursor_time.is_some(), "--cursor-time"),
512 (self.cursor_id.is_some(), "--cursor-id"),
513 ])
514 }
515}
516
517fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
519 flags
520 .iter()
521 .find_map(|(present, flag)| present.then_some(*flag))
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub enum WatchTarget {
527 All,
529 Logs,
531 Issues,
533 Actions,
535}
536
537#[derive(Debug, Clone, Default, PartialEq, Eq)]
539pub struct WatchOptions {
540 pub severity: Vec<String>,
542}
543
544#[derive(Debug, Clone, Default, PartialEq, Eq)]
546pub struct ProjectSetupSeenOptions {
547 pub runtime: Option<String>,
549 pub source: Option<String>,
551 pub environment: Option<String>,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct ProjectCreateOptions {
558 pub name: String,
560 pub runtime: Option<String>,
562 pub environment: Option<String>,
564 pub ingest_key_file: String,
566 pub abandon_retry: bool,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct ProjectIngestKeyCreateOptions {
573 pub project_id: String,
575 pub label: String,
577 pub kind: String,
579 pub ingest_key_file: String,
581 pub abandon_retry: bool,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
587pub enum NativeDebugArtifactsTarget {
588 Upload(NativeDebugUploadOptions),
590 Lookup(NativeDebugLookupOptions),
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
596pub struct NativeDebugLookupOptions {
597 pub project_id: String,
599 pub release: String,
601 pub environment: String,
603 pub service: String,
605 pub image_uuid: String,
607 pub architecture: String,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct NativeDebugUploadOptions {
614 pub path: String,
616 pub project_id: String,
618 pub release: String,
620 pub environment: String,
622 pub service: String,
624 pub expected_image_uuids: Vec<String>,
626 pub dry_run: bool,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum SupportTarget {
633 Create(Box<SupportTicketCreateOptions>),
635 List(Box<SupportTicketListOptions>),
637 Detail(String),
639 ContextHistory {
641 ticket_id: String,
643 },
644 ReplyContext(Box<SupportContextReplyOptions>),
646 UpdateStatus {
648 ticket_id: String,
650 status: SupportTicketLifecycleStatus,
652 },
653}
654
655#[derive(Debug, Clone, Default, PartialEq, Eq)]
657pub struct SupportContextReplyOptions {
658 pub ticket_id: String,
660 pub context: String,
662 pub retry_key: String,
664 pub diagnostics: bool,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum SupportTicketLifecycleStatus {
671 Open,
673 Closed,
675}
676
677impl SupportTicketLifecycleStatus {
678 #[must_use]
680 pub const fn as_str(self) -> &'static str {
681 match self {
682 Self::Open => "open",
683 Self::Closed => "closed",
684 }
685 }
686}
687
688#[derive(Debug, Clone, Default, PartialEq, Eq)]
690pub struct SupportTicketCreateOptions {
691 pub category: String,
693 pub title: String,
695 pub description: String,
697 pub project_id: Option<String>,
699 pub environment: Option<String>,
701 pub runtime: Option<String>,
703 pub framework: Option<String>,
705 pub sdk_package: Option<String>,
707 pub sdk_version: Option<String>,
709 pub release: Option<String>,
711 pub trace_id: Option<String>,
713 pub event_id: Option<String>,
715 pub diagnostics: bool,
717}
718
719#[derive(Debug, Clone, Default, PartialEq, Eq)]
721pub struct SupportTicketListOptions {
722 pub project_id: Option<String>,
724 pub status: Option<String>,
726 pub source: Option<String>,
728 pub category: Option<String>,
730 pub release: Option<String>,
732 pub limit: Option<String>,
734 pub pagination: Option<String>,
736 pub cursor_time: Option<String>,
738 pub cursor_id: Option<String>,
740}
741
742#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum ExplainTarget {
745 Issue(String),
747 Trace(String),
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
753pub enum SetTarget {
754 IssueStatus {
756 id: String,
758 status: String,
760 },
761}
762
763#[derive(Debug, Clone, PartialEq, Eq)]
765pub struct CliEnvironment {
766 pub base_url: String,
768 pub token: Option<String>,
770 pub home: Option<std::path::PathBuf>,
772 pub cwd: Option<std::path::PathBuf>,
774}
775
776impl CliEnvironment {
777 #[must_use]
779 pub fn from_process() -> Self {
780 Self {
781 base_url: std::env::var("LOGBREW_API_URL")
782 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
783 token: std::env::var("LOGBREW_TOKEN").ok(),
784 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
785 cwd: std::env::current_dir().ok(),
786 }
787 }
788}
789
790impl Command {
791 #[must_use]
793 pub fn http_path(&self) -> Option<String> {
794 match self {
795 Self::Read {
796 target, options, ..
797 } => Some(read_path(
798 target,
799 &ReadPathFilters {
800 name: options.name.as_deref(),
801 service: options.service.as_deref(),
802 since: options.since.as_deref(),
803 user: options.user.as_deref(),
804 trace: options.trace.as_deref(),
805 level: options.level.as_deref(),
806 search: options.search.as_deref(),
807 project: options.project.as_deref(),
808 release: options.release.as_deref(),
809 environment: options.environment.as_deref(),
810 status: options.status.as_deref(),
811 limit: options.limit.as_deref(),
812 min_duration_ms: options.min_duration_ms.as_deref(),
813 pagination: options.pagination.as_deref(),
814 cursor_time: options.cursor_time.as_deref(),
815 cursor_id: options.cursor_id.as_deref(),
816 },
817 )),
818 Self::Explain { target, .. } => Some(explain_path(target)),
819 Self::Set { target, .. } => Some(set_path(target)),
820 Self::ProjectSetupSeen { project_id, .. } => {
821 Some(format!("/api/projects/{project_id}/setup/seen"))
822 }
823 Self::ProjectArchive { project_id, .. } => Some(format!("/api/projects/{project_id}")),
824 Self::ProjectIngestKeyCreate { options, .. } => {
825 Some(format!("/api/projects/{}/ingest-keys", options.project_id))
826 }
827 Self::ProjectCreate { .. } | Self::Projects { .. } => {
828 Some(String::from("/api/projects"))
829 }
830 Self::Support { target, .. } => Some(support::path(target)),
831 Self::Help { .. }
832 | Self::Login { .. }
833 | Self::Logout { .. }
834 | Self::Setup { .. }
835 | Self::Status { .. }
836 | Self::WhoAmI { .. }
837 | Self::Doctor { .. }
838 | Self::Usage { .. }
839 | Self::Version { .. }
840 | Self::InvestigateIssue { .. }
841 | Self::NativeDebugArtifacts { .. }
842 | Self::Watch { .. } => None,
843 }
844 }
845
846 #[must_use]
848 pub const fn wants_json(&self) -> bool {
849 match self {
850 Self::Help { json, .. }
851 | Self::Login { json, .. }
852 | Self::Logout { json }
853 | Self::Status { json }
854 | Self::WhoAmI { json }
855 | Self::Doctor { json, .. }
856 | Self::ProjectCreate { json, .. }
857 | Self::ProjectIngestKeyCreate { json, .. }
858 | Self::ProjectArchive { json, .. }
859 | Self::Projects { json }
860 | Self::Usage { json }
861 | Self::Version { json }
862 | Self::Read { json, .. }
863 | Self::Watch { json, .. }
864 | Self::Explain { json, .. }
865 | Self::InvestigateIssue { json, .. }
866 | Self::NativeDebugArtifacts { json, .. }
867 | Self::Set { json, .. }
868 | Self::ProjectSetupSeen { json, .. }
869 | Self::Support { json, .. }
870 | Self::Setup { json, .. } => *json,
871 }
872 }
873
874 #[must_use]
876 pub const fn http_method(&self) -> Option<HttpMethod> {
877 match self {
878 Self::ProjectCreate { .. }
879 | Self::ProjectIngestKeyCreate { .. }
880 | Self::ProjectSetupSeen { .. }
881 | Self::Support {
882 target: SupportTarget::Create(_),
883 ..
884 }
885 | Self::Support {
886 target: SupportTarget::ReplyContext(_),
887 ..
888 } => Some(HttpMethod::Post),
889 Self::Support {
890 target: SupportTarget::UpdateStatus { .. },
891 ..
892 }
893 | Self::Set { .. } => Some(HttpMethod::Patch),
894 Self::ProjectArchive { .. } => Some(HttpMethod::Delete),
895 Self::Projects { .. }
896 | Self::Read { .. }
897 | Self::Explain { .. }
898 | Self::Support { .. } => Some(HttpMethod::Get),
899 Self::Help { .. }
900 | Self::Login { .. }
901 | Self::Logout { .. }
902 | Self::Setup { .. }
903 | Self::Status { .. }
904 | Self::WhoAmI { .. }
905 | Self::Doctor { .. }
906 | Self::Usage { .. }
907 | Self::Version { .. }
908 | Self::InvestigateIssue { .. }
909 | Self::NativeDebugArtifacts { .. }
910 | Self::Watch { .. } => None,
911 }
912 }
913
914 #[must_use]
916 pub fn request_body(&self) -> Option<serde_json::Value> {
917 self.request_body_for_token(None)
918 }
919
920 #[must_use]
922 fn request_body_for_token(&self, token: Option<&str>) -> Option<serde_json::Value> {
923 match self {
924 Self::Set {
925 target: SetTarget::IssueStatus { status, .. },
926 ..
927 } => Some(serde_json::json!({ "status": status })),
928 Self::ProjectSetupSeen { options, .. } => Some(project_setup_seen_body(options, token)),
929 Self::ProjectCreate { options, .. } => Some(project_create_body(options)),
930 Self::ProjectIngestKeyCreate { options, .. } => {
931 Some(project_ingest_key_create_body(options))
932 }
933 Self::Support {
934 target: SupportTarget::Create(options),
935 ..
936 } => Some(support::create_body(options)),
937 Self::Support {
938 target: SupportTarget::ReplyContext(options),
939 ..
940 } => Some(support::context_body(options)),
941 Self::Support {
942 target: SupportTarget::UpdateStatus { status, .. },
943 ..
944 } => Some(serde_json::json!({"status": status.as_str()})),
945 Self::Help { .. }
946 | Self::Login { .. }
947 | Self::Logout { .. }
948 | Self::Setup { .. }
949 | Self::Status { .. }
950 | Self::WhoAmI { .. }
951 | Self::Doctor { .. }
952 | Self::ProjectArchive { .. }
953 | Self::Projects { .. }
954 | Self::Usage { .. }
955 | Self::Version { .. }
956 | Self::Read { .. }
957 | Self::Watch { .. }
958 | Self::Explain { .. }
959 | Self::InvestigateIssue { .. }
960 | Self::NativeDebugArtifacts { .. }
961 | Self::Support { .. } => None,
962 }
963 }
964
965 fn idempotency_key(&self) -> Option<&str> {
967 match self {
968 Self::Support {
969 target: SupportTarget::ReplyContext(options),
970 ..
971 } => Some(options.retry_key.as_str()),
972 Self::Help { .. }
973 | Self::Login { .. }
974 | Self::Logout { .. }
975 | Self::Setup { .. }
976 | Self::Status { .. }
977 | Self::WhoAmI { .. }
978 | Self::Doctor { .. }
979 | Self::Usage { .. }
980 | Self::Version { .. }
981 | Self::Read { .. }
982 | Self::Watch { .. }
983 | Self::Explain { .. }
984 | Self::InvestigateIssue { .. }
985 | Self::NativeDebugArtifacts { .. }
986 | Self::Set { .. }
987 | Self::ProjectSetupSeen { .. }
988 | Self::ProjectCreate { .. }
989 | Self::ProjectIngestKeyCreate { .. }
990 | Self::ProjectArchive { .. }
991 | Self::Projects { .. }
992 | Self::Support { .. } => None,
993 }
994 }
995}
996
997#[derive(Debug, Clone, Copy, PartialEq, Eq)]
999pub enum HttpMethod {
1000 Get,
1002 Post,
1004 Patch,
1006 Delete,
1008}
1009
1010fn project_setup_seen_body(
1012 options: &ProjectSetupSeenOptions,
1013 token: Option<&str>,
1014) -> serde_json::Value {
1015 let mut body = serde_json::Map::new();
1016 if let Some(runtime) = options.runtime.as_ref() {
1017 drop(body.insert(
1018 "runtime".to_owned(),
1019 serde_json::Value::String(runtime.clone()),
1020 ));
1021 }
1022 if let Some(source) = setup_seen_source(options, token) {
1023 drop(body.insert("source".to_owned(), serde_json::Value::String(source)));
1024 }
1025 if let Some(environment) = options.environment.as_ref() {
1026 drop(body.insert(
1027 "environment".to_owned(),
1028 serde_json::Value::String(environment.clone()),
1029 ));
1030 }
1031 serde_json::Value::Object(body)
1032}
1033
1034fn project_create_body(options: &ProjectCreateOptions) -> serde_json::Value {
1036 let mut body = serde_json::Map::new();
1037 drop(body.insert(
1038 "name".to_owned(),
1039 serde_json::Value::String(options.name.clone()),
1040 ));
1041 if let Some(runtime) = options.runtime.as_ref() {
1042 drop(body.insert(
1043 "runtime".to_owned(),
1044 serde_json::Value::String(runtime.clone()),
1045 ));
1046 }
1047 if let Some(environment) = options.environment.as_ref() {
1048 drop(body.insert(
1049 "environment".to_owned(),
1050 serde_json::Value::String(environment.clone()),
1051 ));
1052 }
1053 drop(body.insert(
1054 "source".to_owned(),
1055 serde_json::Value::String(String::from("cli")),
1056 ));
1057 serde_json::Value::Object(body)
1058}
1059
1060fn project_ingest_key_create_body(options: &ProjectIngestKeyCreateOptions) -> serde_json::Value {
1062 serde_json::json!({
1063 "label": options.label,
1064 "kind": options.kind,
1065 "expires_at": null,
1066 })
1067}
1068
1069fn setup_seen_source(options: &ProjectSetupSeenOptions, token: Option<&str>) -> Option<String> {
1071 if token_is_project_ingest_key(token) {
1072 return None;
1073 }
1074 Some(options.source.as_deref().unwrap_or("cli").to_owned())
1075}
1076
1077pub async fn execute_command<W: std::io::Write>(
1083 command: &Command,
1084 env: &CliEnvironment,
1085 output: &mut W,
1086) -> Result<(), RuntimeError> {
1087 match command {
1088 Command::Help { topic, json } => execute_help(*topic, *json, output),
1089 Command::Login {
1090 provider,
1091 open_browser,
1092 json,
1093 } => execute_login(env, *provider, *open_browser, *json, output).await,
1094 Command::Logout { json } => execute_logout(env, *json, output).await,
1095 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
1096 Command::Status { json } => execute_status(env, *json, output).await,
1097 Command::WhoAmI { json } => execute_whoami(env, *json, output).await,
1098 Command::Doctor { project_id, json } => {
1099 doctor::execute(env, project_id.as_str(), *json, output).await
1100 }
1101 Command::ProjectCreate { options, json } => {
1102 project_create::execute(env, options, *json, output).await
1103 }
1104 Command::ProjectIngestKeyCreate { options, json } => {
1105 project_create::execute_ingest_key_create(env, options, *json, output).await
1106 }
1107 Command::ProjectArchive { project_id, json } => {
1108 project_archive::execute(env, project_id.as_str(), *json, output).await
1109 }
1110 Command::Projects { json } => projects::execute(env, *json, output).await,
1111 Command::Usage { json } => usage::execute(env, *json, output).await,
1112 Command::Version { json } => execute_version(*json, output),
1113 Command::InvestigateIssue { issue_id, json } => {
1114 investigate::execute(env, issue_id.as_str(), *json, output).await
1115 }
1116 Command::NativeDebugArtifacts { target, json } => {
1117 native_debug_artifacts::execute(env, target, *json, output).await
1118 }
1119 Command::Read { .. }
1120 | Command::Explain { .. }
1121 | Command::Set { .. }
1122 | Command::ProjectSetupSeen { .. }
1123 | Command::Support { .. } => execute_http(command, env, output).await,
1124 Command::Watch {
1125 target,
1126 options,
1127 json,
1128 } => execute_watch(env, *target, options, *json, output).await,
1129 }
1130}
1131
1132fn execute_help<W: std::io::Write>(
1134 topic: HelpTopic,
1135 json: bool,
1136 output: &mut W,
1137) -> Result<(), RuntimeError> {
1138 let help = help::help_text(topic);
1139 if json {
1140 let body = serde_json::json!({
1141 "ok": true,
1142 "topic": topic.key(),
1143 "help": help,
1144 });
1145 writeln!(output, "{body}")?;
1146 } else {
1147 writeln!(output, "{help}")?;
1148 }
1149 Ok(())
1150}
1151
1152fn execute_setup<W: std::io::Write>(
1154 env: &CliEnvironment,
1155 auto: bool,
1156 yes: bool,
1157 json: bool,
1158 output: &mut W,
1159) -> Result<(), RuntimeError> {
1160 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
1161 Ok(())
1162}
1163
1164async fn execute_http<W: std::io::Write>(
1166 command: &Command,
1167 env: &CliEnvironment,
1168 output: &mut W,
1169) -> Result<(), RuntimeError> {
1170 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
1171 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
1172 let client = reqwest::Client::builder()
1173 .timeout(std::time::Duration::from_secs(30))
1174 .connect_timeout(std::time::Duration::from_secs(10))
1175 .build()?;
1176
1177 let support_command = matches!(command, Command::Support { .. });
1178 let response_result = send_authenticated_with_refresh(&client, env, |client, credential| {
1179 build_command_request(client, command, url.as_str(), credential)
1180 })
1181 .await;
1182 let (response, credential) = match response_result {
1183 Ok(response) => response,
1184 Err(RuntimeError::Http(_)) if support_command => return Err(support_transport_error()),
1185 Err(error) => return Err(error),
1186 };
1187 let status = response.status();
1188 let body = match response.text().await {
1189 Ok(body) => body,
1190 Err(_) if support_command => return Err(support_transport_error()),
1191 Err(error) => return Err(RuntimeError::Http(error)),
1192 };
1193
1194 if !status.is_success() {
1195 let body = if let Command::Support { target, .. } = command {
1196 support::safe_error_body(target, status.as_u16())
1197 } else {
1198 credential.redact_response_body(body.as_str())
1199 };
1200 return Err(RuntimeError::Api {
1201 status: status.as_u16(),
1202 body,
1203 auth_source: credential.source(),
1204 auth_label: credential.label(),
1205 });
1206 }
1207
1208 write_api_success(command, body.as_str(), output)?;
1209 Ok(())
1210}
1211
1212const fn support_transport_error() -> RuntimeError {
1214 RuntimeError::Unavailable {
1215 message: "support request could not be completed",
1216 next: "check network connectivity and retry the support command",
1217 }
1218}
1219
1220fn build_command_request(
1222 client: &reqwest::Client,
1223 command: &Command,
1224 url: &str,
1225 credential: &AuthCredential,
1226) -> reqwest::RequestBuilder {
1227 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
1228 HttpMethod::Get => client.get(url),
1229 HttpMethod::Post => client.post(url),
1230 HttpMethod::Patch => client.patch(url),
1231 HttpMethod::Delete => client.delete(url),
1232 }
1233 .bearer_auth(credential.token());
1234 if let Some(body) = command.request_body_for_token(Some(credential.token())) {
1235 request = request.json(&body);
1236 }
1237 if let Some(key) = command.idempotency_key() {
1238 request = request.header("Idempotency-Key", key);
1239 }
1240 request
1241}
1242
1243async fn execute_watch<W: std::io::Write>(
1245 env: &CliEnvironment,
1246 target: WatchTarget,
1247 options: &WatchOptions,
1248 json: bool,
1249 output: &mut W,
1250) -> Result<(), RuntimeError> {
1251 if !json {
1252 return Err(RuntimeError::Unavailable {
1253 message: "watch streams JSON for agents",
1254 next: "run logbrew watch --json",
1255 });
1256 }
1257
1258 let mut reconnect_backoff = WatchReconnectBackoff::default();
1259 loop {
1260 let ticket = match request_feed_ticket(env).await {
1261 Ok(ticket) => ticket,
1262 Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
1263 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1264 continue;
1265 }
1266 Err(error) => return Err(error),
1267 };
1268 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
1269 let (mut websocket, _) = match connect_async(live_url.as_str()).await {
1270 Ok(connection) => connection,
1271 Err(error)
1272 if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
1273 {
1274 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1275 continue;
1276 }
1277 Err(error) => return Err(map_websocket_connect_error(error)),
1278 };
1279 reconnect_backoff.mark_connected();
1280
1281 let mut emitted_before_disconnect = false;
1282 loop {
1283 let Some(message) = websocket.next().await else {
1284 break;
1285 };
1286 let message = match message {
1287 Ok(message) => message,
1288 Err(error) if websocket_error_is_auth(&error) => {
1289 return Err(map_websocket_stream_error(error));
1290 }
1291 Err(_) => break,
1292 };
1293 match message {
1294 Message::Text(text) => {
1295 let event = parse_live_event(text.as_str())?;
1296 if watch_event_matches(target, options, &event) {
1297 writeln!(output, "{event}")?;
1298 }
1299 emitted_before_disconnect = true;
1300 }
1301 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
1302 Message::Close(_) => return Ok(()),
1303 }
1304 }
1305 if emitted_before_disconnect {
1306 reconnect_backoff.reset();
1307 }
1308 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1309 }
1310}
1311
1312#[derive(Debug, Default)]
1314struct WatchReconnectBackoff {
1315 connected_once: bool,
1317 attempts: u32,
1319}
1320
1321impl WatchReconnectBackoff {
1322 const fn connected_once(&self) -> bool {
1324 self.connected_once
1325 }
1326
1327 const fn mark_connected(&mut self) {
1329 self.connected_once = true;
1330 }
1331
1332 const fn reset(&mut self) {
1334 self.attempts = 0;
1335 }
1336
1337 fn next_delay(&mut self) -> std::time::Duration {
1339 let exponent = self.attempts.min(5);
1340 let multiplier = 1_u64 << exponent;
1341 self.attempts = self.attempts.saturating_add(1);
1342 let base = WATCH_RECONNECT_INITIAL_DELAY
1343 .as_secs()
1344 .saturating_mul(multiplier)
1345 .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
1346 let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
1347 if delay > WATCH_RECONNECT_MAX_DELAY {
1348 WATCH_RECONNECT_MAX_DELAY
1349 } else {
1350 delay
1351 }
1352 }
1353}
1354
1355fn watch_reconnect_jitter() -> std::time::Duration {
1357 let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
1358 return std::time::Duration::ZERO;
1359 };
1360 std::time::Duration::from_millis(
1361 u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
1362 )
1363}
1364
1365const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
1367 matches!(
1368 error,
1369 RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
1370 )
1371}
1372
1373fn websocket_error_is_auth(error: &WebSocketError) -> bool {
1375 matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
1376}
1377
1378async fn request_feed_ticket(env: &CliEnvironment) -> Result<String, RuntimeError> {
1380 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
1381 let client = reqwest::Client::builder()
1382 .timeout(std::time::Duration::from_secs(30))
1383 .connect_timeout(std::time::Duration::from_secs(10))
1384 .build()?;
1385 let (response, credential) =
1386 send_authenticated_with_refresh(&client, env, |client, credential| {
1387 client.post(url.as_str()).bearer_auth(credential.token())
1388 })
1389 .await?;
1390 let status = response.status();
1391 let body = response.text().await?;
1392 if !status.is_success() {
1393 return Err(RuntimeError::Api {
1394 status: status.as_u16(),
1395 body: credential.redact_response_body(body.as_str()),
1396 auth_source: credential.source(),
1397 auth_label: credential.label(),
1398 });
1399 }
1400
1401 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
1402 RuntimeError::Unavailable {
1403 message: "feed ticket response was not valid JSON",
1404 next: "retry logbrew watch or run logbrew status",
1405 }
1406 })?;
1407 value
1408 .get("ticket")
1409 .and_then(serde_json::Value::as_str)
1410 .map(str::trim)
1411 .filter(|ticket| !ticket.is_empty())
1412 .map(ToOwned::to_owned)
1413 .ok_or(RuntimeError::Unavailable {
1414 message: "feed ticket response did not include a ticket",
1415 next: "retry logbrew watch or run logbrew status",
1416 })
1417}
1418
1419fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
1421 let trimmed = base_url.trim_end_matches('/');
1422 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
1423 message: "LOGBREW_API_URL must start with http:// or https://",
1424 next: "check LOGBREW_API_URL or run logbrew status",
1425 })?;
1426 Ok(format!(
1427 "{scheme}://{rest}/api/feed/live?ticket={}",
1428 encode_component(ticket)
1429 ))
1430}
1431
1432fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
1434 base_url
1435 .strip_prefix("https://")
1436 .map(|rest| ("wss", rest))
1437 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
1438}
1439
1440fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
1442 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
1443 message: "live watch event was not valid JSON",
1444 next: "retry logbrew watch or check LOGBREW_API_URL",
1445 })
1446}
1447
1448fn watch_event_matches(
1450 target: WatchTarget,
1451 options: &WatchOptions,
1452 event: &serde_json::Value,
1453) -> bool {
1454 target_matches_event(target, event) && severity_matches(options, event)
1455}
1456
1457fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
1459 let event_type = event
1460 .get("type")
1461 .and_then(serde_json::Value::as_str)
1462 .unwrap_or_default();
1463 match target {
1464 WatchTarget::All => true,
1465 WatchTarget::Logs => event_type == "native_log",
1466 WatchTarget::Issues => event_type == "native_issue",
1467 WatchTarget::Actions => event_type == "native_action",
1468 }
1469}
1470
1471fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
1473 if options.severity.is_empty() {
1474 return true;
1475 }
1476 let Some(severity) = event
1477 .get("data")
1478 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
1479 .and_then(serde_json::Value::as_str)
1480 else {
1481 return false;
1482 };
1483 options
1484 .severity
1485 .iter()
1486 .any(|allowed| allowed.as_str() == severity)
1487}
1488
1489fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
1491 match error {
1492 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1493 RuntimeError::Unavailable {
1494 message: "live watch ticket was rejected",
1495 next: "run logbrew login",
1496 }
1497 }
1498 WebSocketError::Http(_) => RuntimeError::Unavailable {
1499 message: "live watch websocket upgrade failed",
1500 next: "retry logbrew watch or check LOGBREW_API_URL",
1501 },
1502 WebSocketError::ConnectionClosed
1503 | WebSocketError::AlreadyClosed
1504 | WebSocketError::Io(_)
1505 | WebSocketError::Tls(_)
1506 | WebSocketError::Capacity(_)
1507 | WebSocketError::Protocol(_)
1508 | WebSocketError::WriteBufferFull(_)
1509 | WebSocketError::Utf8(_)
1510 | WebSocketError::AttackAttempt
1511 | WebSocketError::Url(_)
1512 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1513 message: "live watch websocket failed",
1514 next: "retry logbrew watch or check LOGBREW_API_URL",
1515 },
1516 }
1517}
1518
1519fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
1521 match error {
1522 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
1523 RuntimeError::Unavailable {
1524 message: "live watch websocket closed",
1525 next: "retry logbrew watch",
1526 }
1527 }
1528 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1529 RuntimeError::Unavailable {
1530 message: "live watch ticket was rejected",
1531 next: "run logbrew login",
1532 }
1533 }
1534 WebSocketError::Http(_)
1535 | WebSocketError::Io(_)
1536 | WebSocketError::Tls(_)
1537 | WebSocketError::Capacity(_)
1538 | WebSocketError::Protocol(_)
1539 | WebSocketError::WriteBufferFull(_)
1540 | WebSocketError::Utf8(_)
1541 | WebSocketError::AttackAttempt
1542 | WebSocketError::Url(_)
1543 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1544 message: "live watch websocket failed",
1545 next: "retry logbrew watch or check LOGBREW_API_URL",
1546 },
1547 }
1548}
1549
1550struct ReadPathFilters<'a> {
1552 name: Option<&'a str>,
1554 service: Option<&'a str>,
1556 since: Option<&'a str>,
1558 user: Option<&'a str>,
1560 trace: Option<&'a str>,
1562 level: Option<&'a str>,
1564 search: Option<&'a str>,
1566 project: Option<&'a str>,
1568 release: Option<&'a str>,
1570 environment: Option<&'a str>,
1572 status: Option<&'a str>,
1574 limit: Option<&'a str>,
1576 min_duration_ms: Option<&'a str>,
1578 pagination: Option<&'a str>,
1580 cursor_time: Option<&'a str>,
1582 cursor_id: Option<&'a str>,
1584}
1585
1586fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
1588 match target {
1589 ReadTarget::Logs => path_with_query(
1590 "/api/logs",
1591 &[
1592 ("service_name", filters.service),
1593 ("severity", filters.level),
1594 ("search", filters.search),
1595 ("since", filters.since),
1596 ("trace_id", filters.trace),
1597 ("project_id", filters.project),
1598 ("release", filters.release),
1599 ("environment", filters.environment),
1600 ("pagination", filters.pagination),
1601 ("cursor_time", filters.cursor_time),
1602 ("cursor_id", filters.cursor_id),
1603 ("limit", filters.limit),
1604 ],
1605 ),
1606 ReadTarget::Issues => path_with_query(
1607 "/api/telemetry/issues",
1608 &[
1609 ("service_name", filters.service),
1610 ("since", filters.since),
1611 ("status", filters.status),
1612 ("project_id", filters.project),
1613 ("release", filters.release),
1614 ("environment", filters.environment),
1615 ("pagination", filters.pagination),
1616 ("cursor_time", filters.cursor_time),
1617 ("cursor_id", filters.cursor_id),
1618 ("limit", filters.limit),
1619 ],
1620 ),
1621 ReadTarget::Actions => path_with_query(
1622 "/api/telemetry/actions",
1623 &[
1624 ("service_name", filters.service),
1625 ("name", filters.name),
1626 ("since", filters.since),
1627 ("distinct_id", filters.user),
1628 ("project_id", filters.project),
1629 ("release", filters.release),
1630 ("environment", filters.environment),
1631 ("pagination", filters.pagination),
1632 ("cursor_time", filters.cursor_time),
1633 ("cursor_id", filters.cursor_id),
1634 ("limit", filters.limit),
1635 ],
1636 ),
1637 ReadTarget::Releases => path_with_query(
1638 "/api/telemetry/releases",
1639 &[
1640 ("service_name", filters.service),
1641 ("since", filters.since),
1642 ("project_id", filters.project),
1643 ("release", filters.release),
1644 ("environment", filters.environment),
1645 ("limit", filters.limit),
1646 ],
1647 ),
1648 ReadTarget::Traces => path_with_query(
1649 "/api/telemetry/traces",
1650 &[
1651 ("project_id", filters.project),
1652 ("service_name", filters.service),
1653 ("release", filters.release),
1654 ("environment", filters.environment),
1655 ("status", filters.status),
1656 ("since", filters.since),
1657 ("min_duration_ms", filters.min_duration_ms),
1658 ("limit", filters.limit),
1659 ],
1660 ),
1661 ReadTarget::Trace(id) => path_with_query(
1662 &format!("/api/telemetry/traces/{}", encode_component(id)),
1663 &[
1664 ("project_id", filters.project),
1665 ("release", filters.release),
1666 ("environment", filters.environment),
1667 ],
1668 ),
1669 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1670 }
1671}
1672
1673fn explain_path(target: &ExplainTarget) -> String {
1675 match target {
1676 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1677 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1678 }
1679}
1680
1681fn set_path(target: &SetTarget) -> String {
1683 match target {
1684 SetTarget::IssueStatus { id, .. } => {
1685 format!("/api/telemetry/issues/{}", encode_component(id))
1686 }
1687 }
1688}
1689
1690fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1692 let query = params
1693 .iter()
1694 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1695 .collect::<Vec<_>>();
1696
1697 if query.is_empty() {
1698 path.to_owned()
1699 } else {
1700 format!("{path}?{}", query.join("&"))
1701 }
1702}
1703
1704fn encode_component(value: &str) -> String {
1706 let mut encoded = String::new();
1707 for byte in value.bytes() {
1708 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1709 encoded.push(char::from(byte));
1710 } else {
1711 encoded.push('%');
1712 encoded.push(hex_digit(byte >> 4));
1713 encoded.push(hex_digit(byte & 0x0f));
1714 }
1715 }
1716 encoded
1717}
1718
1719fn hex_digit(nibble: u8) -> char {
1721 match nibble {
1722 0..=9 => char::from(b'0' + nibble),
1723 10..=15 => char::from(b'A' + (nibble - 10)),
1724 _ => '?',
1725 }
1726}