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 ProjectArchive {
168 project_id: String,
170 json: bool,
172 },
173 Projects {
175 json: bool,
177 },
178 Usage {
180 json: bool,
182 },
183 Version {
185 json: bool,
187 },
188 Read {
190 target: ReadTarget,
192 options: Box<ReadOptions>,
194 json: bool,
196 },
197 Watch {
199 target: WatchTarget,
201 options: WatchOptions,
203 json: bool,
205 },
206 Explain {
208 target: ExplainTarget,
210 json: bool,
212 },
213 InvestigateIssue {
215 issue_id: String,
217 json: bool,
219 },
220 NativeDebugArtifacts {
222 target: NativeDebugArtifactsTarget,
224 json: bool,
226 },
227 Set {
229 target: SetTarget,
231 json: bool,
233 },
234 ProjectSetupSeen {
236 project_id: String,
238 options: ProjectSetupSeenOptions,
240 json: bool,
242 },
243 Support {
245 target: SupportTarget,
247 json: bool,
249 },
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum HelpTopic {
255 Root,
257 Login,
259 Logout,
261 Setup,
263 Status,
265 Version,
267 Auth,
269 Json,
271 Examples,
273 Projects,
275 Usage,
277 Read,
279 ReadLogs,
281 ReadIssues,
283 ReadActions,
285 ReadReleases,
287 ReadTraces,
289 ReadTrace,
291 ReadIssue,
293 Watch,
295 Explain,
297 Investigate,
299 NativeDebugArtifacts,
301 Set,
303 Support,
305}
306
307impl HelpTopic {
308 #[must_use]
310 pub const fn key(self) -> &'static str {
311 match self {
312 Self::Root => "root",
313 Self::Login => "login",
314 Self::Logout => "logout",
315 Self::Setup => "setup",
316 Self::Status => "status",
317 Self::Version => "version",
318 Self::Auth => "auth",
319 Self::Json => "json",
320 Self::Examples => "examples",
321 Self::Projects => "projects",
322 Self::Usage => "usage",
323 Self::Read => "read",
324 Self::ReadLogs => "read_logs",
325 Self::ReadIssues => "read_issues",
326 Self::ReadActions => "read_actions",
327 Self::ReadReleases => "read_releases",
328 Self::ReadTraces => "read_traces",
329 Self::ReadTrace => "read_trace",
330 Self::ReadIssue => "read_issue",
331 Self::Watch => "watch",
332 Self::Explain => "explain",
333 Self::Investigate => "investigate",
334 Self::NativeDebugArtifacts => "debug_artifacts",
335 Self::Set => "set",
336 Self::Support => "support",
337 }
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum ReadTarget {
344 Logs,
346 Issues,
348 Actions,
350 Releases,
352 Traces,
354 Trace(String),
356 Issue(String),
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Eq)]
362pub struct ReadOptions {
363 pub name: Option<String>,
365 pub service: Option<String>,
367 pub since: Option<String>,
369 pub user: Option<String>,
371 pub trace: Option<String>,
373 pub level: Option<String>,
375 pub search: Option<String>,
377 pub project: Option<String>,
379 pub release: Option<String>,
381 pub environment: Option<String>,
383 pub status: Option<String>,
385 pub limit: Option<String>,
387 pub min_duration_ms: Option<String>,
389 pub pagination: Option<String>,
391 pub cursor_time: Option<String>,
393 pub cursor_id: Option<String>,
395}
396
397impl ReadOptions {
398 #[must_use]
400 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
401 first_present_flag([
402 (self.name.is_some(), "--name"),
403 (self.service.is_some(), "--service"),
404 (self.since.is_some(), "--since"),
405 (self.user.is_some(), "--user"),
406 (self.trace.is_some(), "--trace"),
407 (self.level.is_some(), "--severity"),
408 (self.search.is_some(), "--search"),
409 (self.status.is_some(), "--status"),
410 (self.limit.is_some(), "--limit"),
411 (self.min_duration_ms.is_some(), "--min-duration-ms"),
412 (self.pagination.is_some(), "--pagination"),
413 (self.cursor_time.is_some(), "--cursor-time"),
414 (self.cursor_id.is_some(), "--cursor-id"),
415 ])
416 }
417
418 #[must_use]
420 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
421 first_present_flag([
422 (self.name.is_some(), "--name"),
423 (self.service.is_some(), "--service"),
424 (self.since.is_some(), "--since"),
425 (self.user.is_some(), "--user"),
426 (self.trace.is_some(), "--trace"),
427 (self.level.is_some(), "--severity"),
428 (self.search.is_some(), "--search"),
429 (self.project.is_some(), "--project"),
430 (self.release.is_some(), "--release"),
431 (self.environment.is_some(), "--environment"),
432 (self.status.is_some(), "--status"),
433 (self.limit.is_some(), "--limit"),
434 (self.min_duration_ms.is_some(), "--min-duration-ms"),
435 (self.pagination.is_some(), "--pagination"),
436 (self.cursor_time.is_some(), "--cursor-time"),
437 (self.cursor_id.is_some(), "--cursor-id"),
438 ])
439 }
440
441 #[must_use]
443 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
444 first_present_flag([
445 (self.name.is_some(), "--name"),
446 (self.user.is_some(), "--user"),
447 (self.status.is_some(), "--status"),
448 (self.min_duration_ms.is_some(), "--min-duration-ms"),
449 ])
450 }
451
452 #[must_use]
454 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
455 first_present_flag([
456 (self.name.is_some(), "--name"),
457 (self.user.is_some(), "--user"),
458 (self.trace.is_some(), "--trace"),
459 (self.level.is_some(), "--severity"),
460 (self.search.is_some(), "--search"),
461 (self.min_duration_ms.is_some(), "--min-duration-ms"),
462 ])
463 }
464
465 #[must_use]
467 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
468 first_present_flag([
469 (self.trace.is_some(), "--trace"),
470 (self.level.is_some(), "--severity"),
471 (self.search.is_some(), "--search"),
472 (self.status.is_some(), "--status"),
473 (self.min_duration_ms.is_some(), "--min-duration-ms"),
474 ])
475 }
476
477 #[must_use]
479 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
480 first_present_flag([
481 (self.name.is_some(), "--name"),
482 (self.user.is_some(), "--user"),
483 (self.trace.is_some(), "--trace"),
484 (self.level.is_some(), "--severity"),
485 (self.search.is_some(), "--search"),
486 (self.status.is_some(), "--status"),
487 (self.min_duration_ms.is_some(), "--min-duration-ms"),
488 (self.pagination.is_some(), "--pagination"),
489 (self.cursor_time.is_some(), "--cursor-time"),
490 (self.cursor_id.is_some(), "--cursor-id"),
491 ])
492 }
493
494 #[must_use]
496 pub(crate) fn first_trace_list_unsupported_flag(&self) -> Option<&'static str> {
497 first_present_flag([
498 (self.name.is_some(), "--name"),
499 (self.user.is_some(), "--user"),
500 (self.trace.is_some(), "--trace"),
501 (self.level.is_some(), "--severity"),
502 (self.search.is_some(), "--search"),
503 (self.pagination.is_some(), "--pagination"),
504 (self.cursor_time.is_some(), "--cursor-time"),
505 (self.cursor_id.is_some(), "--cursor-id"),
506 ])
507 }
508}
509
510fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
512 flags
513 .iter()
514 .find_map(|(present, flag)| present.then_some(*flag))
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub enum WatchTarget {
520 All,
522 Logs,
524 Issues,
526 Actions,
528}
529
530#[derive(Debug, Clone, Default, PartialEq, Eq)]
532pub struct WatchOptions {
533 pub severity: Vec<String>,
535}
536
537#[derive(Debug, Clone, Default, PartialEq, Eq)]
539pub struct ProjectSetupSeenOptions {
540 pub runtime: Option<String>,
542 pub source: Option<String>,
544 pub environment: Option<String>,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct ProjectCreateOptions {
551 pub name: String,
553 pub runtime: Option<String>,
555 pub environment: Option<String>,
557 pub ingest_key_file: String,
559 pub abandon_retry: bool,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub enum NativeDebugArtifactsTarget {
566 Upload(NativeDebugUploadOptions),
568 Lookup(NativeDebugLookupOptions),
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct NativeDebugLookupOptions {
575 pub project_id: String,
577 pub release: String,
579 pub environment: String,
581 pub service: String,
583 pub image_uuid: String,
585 pub architecture: String,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct NativeDebugUploadOptions {
592 pub path: String,
594 pub project_id: String,
596 pub release: String,
598 pub environment: String,
600 pub service: String,
602 pub expected_image_uuids: Vec<String>,
604 pub dry_run: bool,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq)]
610pub enum SupportTarget {
611 Create(Box<SupportTicketCreateOptions>),
613 List(Box<SupportTicketListOptions>),
615 Detail(String),
617 ContextHistory {
619 ticket_id: String,
621 },
622 ReplyContext(Box<SupportContextReplyOptions>),
624 UpdateStatus {
626 ticket_id: String,
628 status: SupportTicketLifecycleStatus,
630 },
631}
632
633#[derive(Debug, Clone, Default, PartialEq, Eq)]
635pub struct SupportContextReplyOptions {
636 pub ticket_id: String,
638 pub context: String,
640 pub retry_key: String,
642 pub diagnostics: bool,
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648pub enum SupportTicketLifecycleStatus {
649 Open,
651 Closed,
653}
654
655impl SupportTicketLifecycleStatus {
656 #[must_use]
658 pub const fn as_str(self) -> &'static str {
659 match self {
660 Self::Open => "open",
661 Self::Closed => "closed",
662 }
663 }
664}
665
666#[derive(Debug, Clone, Default, PartialEq, Eq)]
668pub struct SupportTicketCreateOptions {
669 pub category: String,
671 pub title: String,
673 pub description: String,
675 pub project_id: Option<String>,
677 pub environment: Option<String>,
679 pub runtime: Option<String>,
681 pub framework: Option<String>,
683 pub sdk_package: Option<String>,
685 pub sdk_version: Option<String>,
687 pub release: Option<String>,
689 pub trace_id: Option<String>,
691 pub event_id: Option<String>,
693 pub diagnostics: bool,
695}
696
697#[derive(Debug, Clone, Default, PartialEq, Eq)]
699pub struct SupportTicketListOptions {
700 pub project_id: Option<String>,
702 pub status: Option<String>,
704 pub source: Option<String>,
706 pub category: Option<String>,
708 pub release: Option<String>,
710 pub limit: Option<String>,
712 pub pagination: Option<String>,
714 pub cursor_time: Option<String>,
716 pub cursor_id: Option<String>,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq)]
722pub enum ExplainTarget {
723 Issue(String),
725 Trace(String),
727}
728
729#[derive(Debug, Clone, PartialEq, Eq)]
731pub enum SetTarget {
732 IssueStatus {
734 id: String,
736 status: String,
738 },
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct CliEnvironment {
744 pub base_url: String,
746 pub token: Option<String>,
748 pub home: Option<std::path::PathBuf>,
750 pub cwd: Option<std::path::PathBuf>,
752}
753
754impl CliEnvironment {
755 #[must_use]
757 pub fn from_process() -> Self {
758 Self {
759 base_url: std::env::var("LOGBREW_API_URL")
760 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
761 token: std::env::var("LOGBREW_TOKEN").ok(),
762 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
763 cwd: std::env::current_dir().ok(),
764 }
765 }
766}
767
768impl Command {
769 #[must_use]
771 pub fn http_path(&self) -> Option<String> {
772 match self {
773 Self::Read {
774 target, options, ..
775 } => Some(read_path(
776 target,
777 &ReadPathFilters {
778 name: options.name.as_deref(),
779 service: options.service.as_deref(),
780 since: options.since.as_deref(),
781 user: options.user.as_deref(),
782 trace: options.trace.as_deref(),
783 level: options.level.as_deref(),
784 search: options.search.as_deref(),
785 project: options.project.as_deref(),
786 release: options.release.as_deref(),
787 environment: options.environment.as_deref(),
788 status: options.status.as_deref(),
789 limit: options.limit.as_deref(),
790 min_duration_ms: options.min_duration_ms.as_deref(),
791 pagination: options.pagination.as_deref(),
792 cursor_time: options.cursor_time.as_deref(),
793 cursor_id: options.cursor_id.as_deref(),
794 },
795 )),
796 Self::Explain { target, .. } => Some(explain_path(target)),
797 Self::Set { target, .. } => Some(set_path(target)),
798 Self::ProjectSetupSeen { project_id, .. } => {
799 Some(format!("/api/projects/{project_id}/setup/seen"))
800 }
801 Self::ProjectArchive { project_id, .. } => Some(format!("/api/projects/{project_id}")),
802 Self::ProjectCreate { .. } | Self::Projects { .. } => {
803 Some(String::from("/api/projects"))
804 }
805 Self::Support { target, .. } => Some(support::path(target)),
806 Self::Help { .. }
807 | Self::Login { .. }
808 | Self::Logout { .. }
809 | Self::Setup { .. }
810 | Self::Status { .. }
811 | Self::WhoAmI { .. }
812 | Self::Doctor { .. }
813 | Self::Usage { .. }
814 | Self::Version { .. }
815 | Self::InvestigateIssue { .. }
816 | Self::NativeDebugArtifacts { .. }
817 | Self::Watch { .. } => None,
818 }
819 }
820
821 #[must_use]
823 pub const fn wants_json(&self) -> bool {
824 match self {
825 Self::Help { json, .. }
826 | Self::Login { json, .. }
827 | Self::Logout { json }
828 | Self::Status { json }
829 | Self::WhoAmI { json }
830 | Self::Doctor { json, .. }
831 | Self::ProjectCreate { json, .. }
832 | Self::ProjectArchive { json, .. }
833 | Self::Projects { json }
834 | Self::Usage { json }
835 | Self::Version { json }
836 | Self::Read { json, .. }
837 | Self::Watch { json, .. }
838 | Self::Explain { json, .. }
839 | Self::InvestigateIssue { json, .. }
840 | Self::NativeDebugArtifacts { json, .. }
841 | Self::Set { json, .. }
842 | Self::ProjectSetupSeen { json, .. }
843 | Self::Support { json, .. }
844 | Self::Setup { json, .. } => *json,
845 }
846 }
847
848 #[must_use]
850 pub const fn http_method(&self) -> Option<HttpMethod> {
851 match self {
852 Self::ProjectCreate { .. }
853 | Self::ProjectSetupSeen { .. }
854 | Self::Support {
855 target: SupportTarget::Create(_),
856 ..
857 }
858 | Self::Support {
859 target: SupportTarget::ReplyContext(_),
860 ..
861 } => Some(HttpMethod::Post),
862 Self::Support {
863 target: SupportTarget::UpdateStatus { .. },
864 ..
865 }
866 | Self::Set { .. } => Some(HttpMethod::Patch),
867 Self::ProjectArchive { .. } => Some(HttpMethod::Delete),
868 Self::Projects { .. }
869 | Self::Read { .. }
870 | Self::Explain { .. }
871 | Self::Support { .. } => Some(HttpMethod::Get),
872 Self::Help { .. }
873 | Self::Login { .. }
874 | Self::Logout { .. }
875 | Self::Setup { .. }
876 | Self::Status { .. }
877 | Self::WhoAmI { .. }
878 | Self::Doctor { .. }
879 | Self::Usage { .. }
880 | Self::Version { .. }
881 | Self::InvestigateIssue { .. }
882 | Self::NativeDebugArtifacts { .. }
883 | Self::Watch { .. } => None,
884 }
885 }
886
887 #[must_use]
889 pub fn request_body(&self) -> Option<serde_json::Value> {
890 self.request_body_for_token(None)
891 }
892
893 #[must_use]
895 fn request_body_for_token(&self, token: Option<&str>) -> Option<serde_json::Value> {
896 match self {
897 Self::Set {
898 target: SetTarget::IssueStatus { status, .. },
899 ..
900 } => Some(serde_json::json!({ "status": status })),
901 Self::ProjectSetupSeen { options, .. } => Some(project_setup_seen_body(options, token)),
902 Self::ProjectCreate { options, .. } => Some(project_create_body(options)),
903 Self::Support {
904 target: SupportTarget::Create(options),
905 ..
906 } => Some(support::create_body(options)),
907 Self::Support {
908 target: SupportTarget::ReplyContext(options),
909 ..
910 } => Some(support::context_body(options)),
911 Self::Support {
912 target: SupportTarget::UpdateStatus { status, .. },
913 ..
914 } => Some(serde_json::json!({"status": status.as_str()})),
915 Self::Help { .. }
916 | Self::Login { .. }
917 | Self::Logout { .. }
918 | Self::Setup { .. }
919 | Self::Status { .. }
920 | Self::WhoAmI { .. }
921 | Self::Doctor { .. }
922 | Self::ProjectArchive { .. }
923 | Self::Projects { .. }
924 | Self::Usage { .. }
925 | Self::Version { .. }
926 | Self::Read { .. }
927 | Self::Watch { .. }
928 | Self::Explain { .. }
929 | Self::InvestigateIssue { .. }
930 | Self::NativeDebugArtifacts { .. }
931 | Self::Support { .. } => None,
932 }
933 }
934
935 fn idempotency_key(&self) -> Option<&str> {
937 match self {
938 Self::Support {
939 target: SupportTarget::ReplyContext(options),
940 ..
941 } => Some(options.retry_key.as_str()),
942 Self::Help { .. }
943 | Self::Login { .. }
944 | Self::Logout { .. }
945 | Self::Setup { .. }
946 | Self::Status { .. }
947 | Self::WhoAmI { .. }
948 | Self::Doctor { .. }
949 | Self::Usage { .. }
950 | Self::Version { .. }
951 | Self::Read { .. }
952 | Self::Watch { .. }
953 | Self::Explain { .. }
954 | Self::InvestigateIssue { .. }
955 | Self::NativeDebugArtifacts { .. }
956 | Self::Set { .. }
957 | Self::ProjectSetupSeen { .. }
958 | Self::ProjectCreate { .. }
959 | Self::ProjectArchive { .. }
960 | Self::Projects { .. }
961 | Self::Support { .. } => None,
962 }
963 }
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum HttpMethod {
969 Get,
971 Post,
973 Patch,
975 Delete,
977}
978
979fn project_setup_seen_body(
981 options: &ProjectSetupSeenOptions,
982 token: Option<&str>,
983) -> serde_json::Value {
984 let mut body = serde_json::Map::new();
985 if let Some(runtime) = options.runtime.as_ref() {
986 drop(body.insert(
987 "runtime".to_owned(),
988 serde_json::Value::String(runtime.clone()),
989 ));
990 }
991 if let Some(source) = setup_seen_source(options, token) {
992 drop(body.insert("source".to_owned(), serde_json::Value::String(source)));
993 }
994 if let Some(environment) = options.environment.as_ref() {
995 drop(body.insert(
996 "environment".to_owned(),
997 serde_json::Value::String(environment.clone()),
998 ));
999 }
1000 serde_json::Value::Object(body)
1001}
1002
1003fn project_create_body(options: &ProjectCreateOptions) -> serde_json::Value {
1005 let mut body = serde_json::Map::new();
1006 drop(body.insert(
1007 "name".to_owned(),
1008 serde_json::Value::String(options.name.clone()),
1009 ));
1010 if let Some(runtime) = options.runtime.as_ref() {
1011 drop(body.insert(
1012 "runtime".to_owned(),
1013 serde_json::Value::String(runtime.clone()),
1014 ));
1015 }
1016 if let Some(environment) = options.environment.as_ref() {
1017 drop(body.insert(
1018 "environment".to_owned(),
1019 serde_json::Value::String(environment.clone()),
1020 ));
1021 }
1022 drop(body.insert(
1023 "source".to_owned(),
1024 serde_json::Value::String(String::from("cli")),
1025 ));
1026 serde_json::Value::Object(body)
1027}
1028
1029fn setup_seen_source(options: &ProjectSetupSeenOptions, token: Option<&str>) -> Option<String> {
1031 if token_is_project_ingest_key(token) {
1032 return None;
1033 }
1034 Some(options.source.as_deref().unwrap_or("cli").to_owned())
1035}
1036
1037pub async fn execute_command<W: std::io::Write>(
1043 command: &Command,
1044 env: &CliEnvironment,
1045 output: &mut W,
1046) -> Result<(), RuntimeError> {
1047 match command {
1048 Command::Help { topic, json } => execute_help(*topic, *json, output),
1049 Command::Login {
1050 provider,
1051 open_browser,
1052 json,
1053 } => execute_login(env, *provider, *open_browser, *json, output).await,
1054 Command::Logout { json } => execute_logout(env, *json, output).await,
1055 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
1056 Command::Status { json } => execute_status(env, *json, output).await,
1057 Command::WhoAmI { json } => execute_whoami(env, *json, output).await,
1058 Command::Doctor { project_id, json } => {
1059 doctor::execute(env, project_id.as_str(), *json, output).await
1060 }
1061 Command::ProjectCreate { options, json } => {
1062 project_create::execute(env, options, *json, output).await
1063 }
1064 Command::ProjectArchive { project_id, json } => {
1065 project_archive::execute(env, project_id.as_str(), *json, output).await
1066 }
1067 Command::Projects { json } => projects::execute(env, *json, output).await,
1068 Command::Usage { json } => usage::execute(env, *json, output).await,
1069 Command::Version { json } => execute_version(*json, output),
1070 Command::InvestigateIssue { issue_id, json } => {
1071 investigate::execute(env, issue_id.as_str(), *json, output).await
1072 }
1073 Command::NativeDebugArtifacts { target, json } => {
1074 native_debug_artifacts::execute(env, target, *json, output).await
1075 }
1076 Command::Read { .. }
1077 | Command::Explain { .. }
1078 | Command::Set { .. }
1079 | Command::ProjectSetupSeen { .. }
1080 | Command::Support { .. } => execute_http(command, env, output).await,
1081 Command::Watch {
1082 target,
1083 options,
1084 json,
1085 } => execute_watch(env, *target, options, *json, output).await,
1086 }
1087}
1088
1089fn execute_help<W: std::io::Write>(
1091 topic: HelpTopic,
1092 json: bool,
1093 output: &mut W,
1094) -> Result<(), RuntimeError> {
1095 let help = help::help_text(topic);
1096 if json {
1097 let body = serde_json::json!({
1098 "ok": true,
1099 "topic": topic.key(),
1100 "help": help,
1101 });
1102 writeln!(output, "{body}")?;
1103 } else {
1104 writeln!(output, "{help}")?;
1105 }
1106 Ok(())
1107}
1108
1109fn execute_setup<W: std::io::Write>(
1111 env: &CliEnvironment,
1112 auto: bool,
1113 yes: bool,
1114 json: bool,
1115 output: &mut W,
1116) -> Result<(), RuntimeError> {
1117 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
1118 Ok(())
1119}
1120
1121async fn execute_http<W: std::io::Write>(
1123 command: &Command,
1124 env: &CliEnvironment,
1125 output: &mut W,
1126) -> Result<(), RuntimeError> {
1127 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
1128 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
1129 let client = reqwest::Client::builder()
1130 .timeout(std::time::Duration::from_secs(30))
1131 .connect_timeout(std::time::Duration::from_secs(10))
1132 .build()?;
1133
1134 let support_command = matches!(command, Command::Support { .. });
1135 let response_result = send_authenticated_with_refresh(&client, env, |client, credential| {
1136 build_command_request(client, command, url.as_str(), credential)
1137 })
1138 .await;
1139 let (response, credential) = match response_result {
1140 Ok(response) => response,
1141 Err(RuntimeError::Http(_)) if support_command => return Err(support_transport_error()),
1142 Err(error) => return Err(error),
1143 };
1144 let status = response.status();
1145 let body = match response.text().await {
1146 Ok(body) => body,
1147 Err(_) if support_command => return Err(support_transport_error()),
1148 Err(error) => return Err(RuntimeError::Http(error)),
1149 };
1150
1151 if !status.is_success() {
1152 let body = if let Command::Support { target, .. } = command {
1153 support::safe_error_body(target, status.as_u16())
1154 } else {
1155 credential.redact_response_body(body.as_str())
1156 };
1157 return Err(RuntimeError::Api {
1158 status: status.as_u16(),
1159 body,
1160 auth_source: credential.source(),
1161 auth_label: credential.label(),
1162 });
1163 }
1164
1165 write_api_success(command, body.as_str(), output)?;
1166 Ok(())
1167}
1168
1169const fn support_transport_error() -> RuntimeError {
1171 RuntimeError::Unavailable {
1172 message: "support request could not be completed",
1173 next: "check network connectivity and retry the support command",
1174 }
1175}
1176
1177fn build_command_request(
1179 client: &reqwest::Client,
1180 command: &Command,
1181 url: &str,
1182 credential: &AuthCredential,
1183) -> reqwest::RequestBuilder {
1184 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
1185 HttpMethod::Get => client.get(url),
1186 HttpMethod::Post => client.post(url),
1187 HttpMethod::Patch => client.patch(url),
1188 HttpMethod::Delete => client.delete(url),
1189 }
1190 .bearer_auth(credential.token());
1191 if let Some(body) = command.request_body_for_token(Some(credential.token())) {
1192 request = request.json(&body);
1193 }
1194 if let Some(key) = command.idempotency_key() {
1195 request = request.header("Idempotency-Key", key);
1196 }
1197 request
1198}
1199
1200async fn execute_watch<W: std::io::Write>(
1202 env: &CliEnvironment,
1203 target: WatchTarget,
1204 options: &WatchOptions,
1205 json: bool,
1206 output: &mut W,
1207) -> Result<(), RuntimeError> {
1208 if !json {
1209 return Err(RuntimeError::Unavailable {
1210 message: "watch streams JSON for agents",
1211 next: "run logbrew watch --json",
1212 });
1213 }
1214
1215 let mut reconnect_backoff = WatchReconnectBackoff::default();
1216 loop {
1217 let ticket = match request_feed_ticket(env).await {
1218 Ok(ticket) => ticket,
1219 Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
1220 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1221 continue;
1222 }
1223 Err(error) => return Err(error),
1224 };
1225 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
1226 let (mut websocket, _) = match connect_async(live_url.as_str()).await {
1227 Ok(connection) => connection,
1228 Err(error)
1229 if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
1230 {
1231 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1232 continue;
1233 }
1234 Err(error) => return Err(map_websocket_connect_error(error)),
1235 };
1236 reconnect_backoff.mark_connected();
1237
1238 let mut emitted_before_disconnect = false;
1239 loop {
1240 let Some(message) = websocket.next().await else {
1241 break;
1242 };
1243 let message = match message {
1244 Ok(message) => message,
1245 Err(error) if websocket_error_is_auth(&error) => {
1246 return Err(map_websocket_stream_error(error));
1247 }
1248 Err(_) => break,
1249 };
1250 match message {
1251 Message::Text(text) => {
1252 let event = parse_live_event(text.as_str())?;
1253 if watch_event_matches(target, options, &event) {
1254 writeln!(output, "{event}")?;
1255 }
1256 emitted_before_disconnect = true;
1257 }
1258 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
1259 Message::Close(_) => return Ok(()),
1260 }
1261 }
1262 if emitted_before_disconnect {
1263 reconnect_backoff.reset();
1264 }
1265 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1266 }
1267}
1268
1269#[derive(Debug, Default)]
1271struct WatchReconnectBackoff {
1272 connected_once: bool,
1274 attempts: u32,
1276}
1277
1278impl WatchReconnectBackoff {
1279 const fn connected_once(&self) -> bool {
1281 self.connected_once
1282 }
1283
1284 const fn mark_connected(&mut self) {
1286 self.connected_once = true;
1287 }
1288
1289 const fn reset(&mut self) {
1291 self.attempts = 0;
1292 }
1293
1294 fn next_delay(&mut self) -> std::time::Duration {
1296 let exponent = self.attempts.min(5);
1297 let multiplier = 1_u64 << exponent;
1298 self.attempts = self.attempts.saturating_add(1);
1299 let base = WATCH_RECONNECT_INITIAL_DELAY
1300 .as_secs()
1301 .saturating_mul(multiplier)
1302 .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
1303 let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
1304 if delay > WATCH_RECONNECT_MAX_DELAY {
1305 WATCH_RECONNECT_MAX_DELAY
1306 } else {
1307 delay
1308 }
1309 }
1310}
1311
1312fn watch_reconnect_jitter() -> std::time::Duration {
1314 let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
1315 return std::time::Duration::ZERO;
1316 };
1317 std::time::Duration::from_millis(
1318 u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
1319 )
1320}
1321
1322const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
1324 matches!(
1325 error,
1326 RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
1327 )
1328}
1329
1330fn websocket_error_is_auth(error: &WebSocketError) -> bool {
1332 matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
1333}
1334
1335async fn request_feed_ticket(env: &CliEnvironment) -> Result<String, RuntimeError> {
1337 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
1338 let client = reqwest::Client::builder()
1339 .timeout(std::time::Duration::from_secs(30))
1340 .connect_timeout(std::time::Duration::from_secs(10))
1341 .build()?;
1342 let (response, credential) =
1343 send_authenticated_with_refresh(&client, env, |client, credential| {
1344 client.post(url.as_str()).bearer_auth(credential.token())
1345 })
1346 .await?;
1347 let status = response.status();
1348 let body = response.text().await?;
1349 if !status.is_success() {
1350 return Err(RuntimeError::Api {
1351 status: status.as_u16(),
1352 body: credential.redact_response_body(body.as_str()),
1353 auth_source: credential.source(),
1354 auth_label: credential.label(),
1355 });
1356 }
1357
1358 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
1359 RuntimeError::Unavailable {
1360 message: "feed ticket response was not valid JSON",
1361 next: "retry logbrew watch or run logbrew status",
1362 }
1363 })?;
1364 value
1365 .get("ticket")
1366 .and_then(serde_json::Value::as_str)
1367 .map(str::trim)
1368 .filter(|ticket| !ticket.is_empty())
1369 .map(ToOwned::to_owned)
1370 .ok_or(RuntimeError::Unavailable {
1371 message: "feed ticket response did not include a ticket",
1372 next: "retry logbrew watch or run logbrew status",
1373 })
1374}
1375
1376fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
1378 let trimmed = base_url.trim_end_matches('/');
1379 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
1380 message: "LOGBREW_API_URL must start with http:// or https://",
1381 next: "check LOGBREW_API_URL or run logbrew status",
1382 })?;
1383 Ok(format!(
1384 "{scheme}://{rest}/api/feed/live?ticket={}",
1385 encode_component(ticket)
1386 ))
1387}
1388
1389fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
1391 base_url
1392 .strip_prefix("https://")
1393 .map(|rest| ("wss", rest))
1394 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
1395}
1396
1397fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
1399 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
1400 message: "live watch event was not valid JSON",
1401 next: "retry logbrew watch or check LOGBREW_API_URL",
1402 })
1403}
1404
1405fn watch_event_matches(
1407 target: WatchTarget,
1408 options: &WatchOptions,
1409 event: &serde_json::Value,
1410) -> bool {
1411 target_matches_event(target, event) && severity_matches(options, event)
1412}
1413
1414fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
1416 let event_type = event
1417 .get("type")
1418 .and_then(serde_json::Value::as_str)
1419 .unwrap_or_default();
1420 match target {
1421 WatchTarget::All => true,
1422 WatchTarget::Logs => event_type == "native_log",
1423 WatchTarget::Issues => event_type == "native_issue",
1424 WatchTarget::Actions => event_type == "native_action",
1425 }
1426}
1427
1428fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
1430 if options.severity.is_empty() {
1431 return true;
1432 }
1433 let Some(severity) = event
1434 .get("data")
1435 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
1436 .and_then(serde_json::Value::as_str)
1437 else {
1438 return false;
1439 };
1440 options
1441 .severity
1442 .iter()
1443 .any(|allowed| allowed.as_str() == severity)
1444}
1445
1446fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
1448 match error {
1449 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1450 RuntimeError::Unavailable {
1451 message: "live watch ticket was rejected",
1452 next: "run logbrew login",
1453 }
1454 }
1455 WebSocketError::Http(_) => RuntimeError::Unavailable {
1456 message: "live watch websocket upgrade failed",
1457 next: "retry logbrew watch or check LOGBREW_API_URL",
1458 },
1459 WebSocketError::ConnectionClosed
1460 | WebSocketError::AlreadyClosed
1461 | WebSocketError::Io(_)
1462 | WebSocketError::Tls(_)
1463 | WebSocketError::Capacity(_)
1464 | WebSocketError::Protocol(_)
1465 | WebSocketError::WriteBufferFull(_)
1466 | WebSocketError::Utf8(_)
1467 | WebSocketError::AttackAttempt
1468 | WebSocketError::Url(_)
1469 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1470 message: "live watch websocket failed",
1471 next: "retry logbrew watch or check LOGBREW_API_URL",
1472 },
1473 }
1474}
1475
1476fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
1478 match error {
1479 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
1480 RuntimeError::Unavailable {
1481 message: "live watch websocket closed",
1482 next: "retry logbrew watch",
1483 }
1484 }
1485 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1486 RuntimeError::Unavailable {
1487 message: "live watch ticket was rejected",
1488 next: "run logbrew login",
1489 }
1490 }
1491 WebSocketError::Http(_)
1492 | WebSocketError::Io(_)
1493 | WebSocketError::Tls(_)
1494 | WebSocketError::Capacity(_)
1495 | WebSocketError::Protocol(_)
1496 | WebSocketError::WriteBufferFull(_)
1497 | WebSocketError::Utf8(_)
1498 | WebSocketError::AttackAttempt
1499 | WebSocketError::Url(_)
1500 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1501 message: "live watch websocket failed",
1502 next: "retry logbrew watch or check LOGBREW_API_URL",
1503 },
1504 }
1505}
1506
1507struct ReadPathFilters<'a> {
1509 name: Option<&'a str>,
1511 service: Option<&'a str>,
1513 since: Option<&'a str>,
1515 user: Option<&'a str>,
1517 trace: Option<&'a str>,
1519 level: Option<&'a str>,
1521 search: Option<&'a str>,
1523 project: Option<&'a str>,
1525 release: Option<&'a str>,
1527 environment: Option<&'a str>,
1529 status: Option<&'a str>,
1531 limit: Option<&'a str>,
1533 min_duration_ms: Option<&'a str>,
1535 pagination: Option<&'a str>,
1537 cursor_time: Option<&'a str>,
1539 cursor_id: Option<&'a str>,
1541}
1542
1543fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
1545 match target {
1546 ReadTarget::Logs => path_with_query(
1547 "/api/logs",
1548 &[
1549 ("service_name", filters.service),
1550 ("severity", filters.level),
1551 ("search", filters.search),
1552 ("since", filters.since),
1553 ("trace_id", filters.trace),
1554 ("project_id", filters.project),
1555 ("release", filters.release),
1556 ("environment", filters.environment),
1557 ("pagination", filters.pagination),
1558 ("cursor_time", filters.cursor_time),
1559 ("cursor_id", filters.cursor_id),
1560 ("limit", filters.limit),
1561 ],
1562 ),
1563 ReadTarget::Issues => path_with_query(
1564 "/api/telemetry/issues",
1565 &[
1566 ("service_name", filters.service),
1567 ("since", filters.since),
1568 ("status", filters.status),
1569 ("project_id", filters.project),
1570 ("release", filters.release),
1571 ("environment", filters.environment),
1572 ("pagination", filters.pagination),
1573 ("cursor_time", filters.cursor_time),
1574 ("cursor_id", filters.cursor_id),
1575 ("limit", filters.limit),
1576 ],
1577 ),
1578 ReadTarget::Actions => path_with_query(
1579 "/api/telemetry/actions",
1580 &[
1581 ("service_name", filters.service),
1582 ("name", filters.name),
1583 ("since", filters.since),
1584 ("distinct_id", filters.user),
1585 ("project_id", filters.project),
1586 ("release", filters.release),
1587 ("environment", filters.environment),
1588 ("pagination", filters.pagination),
1589 ("cursor_time", filters.cursor_time),
1590 ("cursor_id", filters.cursor_id),
1591 ("limit", filters.limit),
1592 ],
1593 ),
1594 ReadTarget::Releases => path_with_query(
1595 "/api/telemetry/releases",
1596 &[
1597 ("service_name", filters.service),
1598 ("since", filters.since),
1599 ("project_id", filters.project),
1600 ("release", filters.release),
1601 ("environment", filters.environment),
1602 ("limit", filters.limit),
1603 ],
1604 ),
1605 ReadTarget::Traces => path_with_query(
1606 "/api/telemetry/traces",
1607 &[
1608 ("project_id", filters.project),
1609 ("service_name", filters.service),
1610 ("release", filters.release),
1611 ("environment", filters.environment),
1612 ("status", filters.status),
1613 ("since", filters.since),
1614 ("min_duration_ms", filters.min_duration_ms),
1615 ("limit", filters.limit),
1616 ],
1617 ),
1618 ReadTarget::Trace(id) => path_with_query(
1619 &format!("/api/telemetry/traces/{}", encode_component(id)),
1620 &[
1621 ("project_id", filters.project),
1622 ("release", filters.release),
1623 ("environment", filters.environment),
1624 ],
1625 ),
1626 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1627 }
1628}
1629
1630fn explain_path(target: &ExplainTarget) -> String {
1632 match target {
1633 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1634 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1635 }
1636}
1637
1638fn set_path(target: &SetTarget) -> String {
1640 match target {
1641 SetTarget::IssueStatus { id, .. } => {
1642 format!("/api/telemetry/issues/{}", encode_component(id))
1643 }
1644 }
1645}
1646
1647fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1649 let query = params
1650 .iter()
1651 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1652 .collect::<Vec<_>>();
1653
1654 if query.is_empty() {
1655 path.to_owned()
1656 } else {
1657 format!("{path}?{}", query.join("&"))
1658 }
1659}
1660
1661fn encode_component(value: &str) -> String {
1663 let mut encoded = String::new();
1664 for byte in value.bytes() {
1665 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1666 encoded.push(char::from(byte));
1667 } else {
1668 encoded.push('%');
1669 encoded.push(hex_digit(byte >> 4));
1670 encoded.push(hex_digit(byte & 0x0f));
1671 }
1672 }
1673 encoded
1674}
1675
1676fn hex_digit(nibble: u8) -> char {
1678 match nibble {
1679 0..=9 => char::from(b'0' + nibble),
1680 10..=15 => char::from(b'A' + (nibble - 10)),
1681 _ => '?',
1682 }
1683}