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, execute_whoami, send_authenticated_with_refresh,
40 token_is_project_ingest_key,
41};
42pub use error::{
43 CliError, RuntimeError, write_cli_error, write_native_debug_runtime_error, write_runtime_error,
44};
45use futures_util::StreamExt as _;
46pub use parser::parse_command;
47use render::write_api_success;
48use setup::write_setup_plan;
49use status::execute_status;
50use tokio_tungstenite::connect_async;
51use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
52use version::execute_version;
53
54const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
56const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
58const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
60
61pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
63 "use one of unresolved/open, resolved/closed, ignored";
64pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
66 "use --status unresolved/open, --status resolved/closed, or --status ignored";
67pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
69 "provide one of unresolved/open, resolved/closed, ignored";
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub enum LoginProvider {
74 #[default]
76 GitHub,
77 GitLab,
79 Bitbucket,
81}
82
83impl LoginProvider {
84 #[must_use]
86 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::GitHub => "github",
89 Self::GitLab => "gitlab",
90 Self::Bitbucket => "bitbucket",
91 }
92 }
93}
94
95impl std::str::FromStr for LoginProvider {
96 type Err = CliError;
97
98 fn from_str(value: &str) -> Result<Self, Self::Err> {
99 match value {
100 "github" => Ok(Self::GitHub),
101 "gitlab" => Ok(Self::GitLab),
102 "bitbucket" => Ok(Self::Bitbucket),
103 _ => Err(CliError::InvalidLoginProvider),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Command {
111 Help {
113 topic: HelpTopic,
115 json: bool,
117 },
118 Login {
120 provider: LoginProvider,
122 open_browser: bool,
124 json: bool,
126 },
127 Logout {
129 json: bool,
131 },
132 Setup {
134 auto: bool,
136 yes: bool,
138 json: bool,
140 },
141 Status {
143 json: bool,
145 },
146 WhoAmI {
148 json: bool,
150 },
151 Doctor {
153 project_id: String,
155 json: bool,
157 },
158 ProjectCreate {
160 options: ProjectCreateOptions,
162 json: bool,
164 },
165 Projects {
167 json: bool,
169 },
170 Usage {
172 json: bool,
174 },
175 Version {
177 json: bool,
179 },
180 Read {
182 target: ReadTarget,
184 options: Box<ReadOptions>,
186 json: bool,
188 },
189 Watch {
191 target: WatchTarget,
193 options: WatchOptions,
195 json: bool,
197 },
198 Explain {
200 target: ExplainTarget,
202 json: bool,
204 },
205 InvestigateIssue {
207 issue_id: String,
209 json: bool,
211 },
212 NativeDebugArtifacts {
214 target: NativeDebugArtifactsTarget,
216 json: bool,
218 },
219 Set {
221 target: SetTarget,
223 json: bool,
225 },
226 ProjectSetupSeen {
228 project_id: String,
230 options: ProjectSetupSeenOptions,
232 json: bool,
234 },
235 Support {
237 target: SupportTarget,
239 json: bool,
241 },
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum HelpTopic {
247 Root,
249 Login,
251 Logout,
253 Setup,
255 Status,
257 Version,
259 Auth,
261 Json,
263 Examples,
265 Projects,
267 Usage,
269 Read,
271 ReadLogs,
273 ReadIssues,
275 ReadActions,
277 ReadReleases,
279 ReadTraces,
281 ReadTrace,
283 ReadIssue,
285 Watch,
287 Explain,
289 Investigate,
291 NativeDebugArtifacts,
293 Set,
295 Support,
297}
298
299impl HelpTopic {
300 #[must_use]
302 pub const fn key(self) -> &'static str {
303 match self {
304 Self::Root => "root",
305 Self::Login => "login",
306 Self::Logout => "logout",
307 Self::Setup => "setup",
308 Self::Status => "status",
309 Self::Version => "version",
310 Self::Auth => "auth",
311 Self::Json => "json",
312 Self::Examples => "examples",
313 Self::Projects => "projects",
314 Self::Usage => "usage",
315 Self::Read => "read",
316 Self::ReadLogs => "read_logs",
317 Self::ReadIssues => "read_issues",
318 Self::ReadActions => "read_actions",
319 Self::ReadReleases => "read_releases",
320 Self::ReadTraces => "read_traces",
321 Self::ReadTrace => "read_trace",
322 Self::ReadIssue => "read_issue",
323 Self::Watch => "watch",
324 Self::Explain => "explain",
325 Self::Investigate => "investigate",
326 Self::NativeDebugArtifacts => "debug_artifacts",
327 Self::Set => "set",
328 Self::Support => "support",
329 }
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum ReadTarget {
336 Logs,
338 Issues,
340 Actions,
342 Releases,
344 Traces,
346 Trace(String),
348 Issue(String),
350}
351
352#[derive(Debug, Clone, Default, PartialEq, Eq)]
354pub struct ReadOptions {
355 pub name: Option<String>,
357 pub service: Option<String>,
359 pub since: Option<String>,
361 pub user: Option<String>,
363 pub trace: Option<String>,
365 pub level: Option<String>,
367 pub search: Option<String>,
369 pub project: Option<String>,
371 pub release: Option<String>,
373 pub environment: Option<String>,
375 pub status: Option<String>,
377 pub limit: Option<String>,
379 pub min_duration_ms: Option<String>,
381 pub pagination: Option<String>,
383 pub cursor_time: Option<String>,
385 pub cursor_id: Option<String>,
387}
388
389impl ReadOptions {
390 #[must_use]
392 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
393 first_present_flag([
394 (self.name.is_some(), "--name"),
395 (self.service.is_some(), "--service"),
396 (self.since.is_some(), "--since"),
397 (self.user.is_some(), "--user"),
398 (self.trace.is_some(), "--trace"),
399 (self.level.is_some(), "--severity"),
400 (self.search.is_some(), "--search"),
401 (self.status.is_some(), "--status"),
402 (self.limit.is_some(), "--limit"),
403 (self.min_duration_ms.is_some(), "--min-duration-ms"),
404 (self.pagination.is_some(), "--pagination"),
405 (self.cursor_time.is_some(), "--cursor-time"),
406 (self.cursor_id.is_some(), "--cursor-id"),
407 ])
408 }
409
410 #[must_use]
412 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
413 first_present_flag([
414 (self.name.is_some(), "--name"),
415 (self.service.is_some(), "--service"),
416 (self.since.is_some(), "--since"),
417 (self.user.is_some(), "--user"),
418 (self.trace.is_some(), "--trace"),
419 (self.level.is_some(), "--severity"),
420 (self.search.is_some(), "--search"),
421 (self.project.is_some(), "--project"),
422 (self.release.is_some(), "--release"),
423 (self.environment.is_some(), "--environment"),
424 (self.status.is_some(), "--status"),
425 (self.limit.is_some(), "--limit"),
426 (self.min_duration_ms.is_some(), "--min-duration-ms"),
427 (self.pagination.is_some(), "--pagination"),
428 (self.cursor_time.is_some(), "--cursor-time"),
429 (self.cursor_id.is_some(), "--cursor-id"),
430 ])
431 }
432
433 #[must_use]
435 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
436 first_present_flag([
437 (self.name.is_some(), "--name"),
438 (self.user.is_some(), "--user"),
439 (self.status.is_some(), "--status"),
440 (self.min_duration_ms.is_some(), "--min-duration-ms"),
441 ])
442 }
443
444 #[must_use]
446 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
447 first_present_flag([
448 (self.name.is_some(), "--name"),
449 (self.user.is_some(), "--user"),
450 (self.trace.is_some(), "--trace"),
451 (self.level.is_some(), "--severity"),
452 (self.search.is_some(), "--search"),
453 (self.min_duration_ms.is_some(), "--min-duration-ms"),
454 ])
455 }
456
457 #[must_use]
459 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
460 first_present_flag([
461 (self.trace.is_some(), "--trace"),
462 (self.level.is_some(), "--severity"),
463 (self.search.is_some(), "--search"),
464 (self.status.is_some(), "--status"),
465 (self.min_duration_ms.is_some(), "--min-duration-ms"),
466 ])
467 }
468
469 #[must_use]
471 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
472 first_present_flag([
473 (self.name.is_some(), "--name"),
474 (self.user.is_some(), "--user"),
475 (self.trace.is_some(), "--trace"),
476 (self.level.is_some(), "--severity"),
477 (self.search.is_some(), "--search"),
478 (self.status.is_some(), "--status"),
479 (self.min_duration_ms.is_some(), "--min-duration-ms"),
480 (self.pagination.is_some(), "--pagination"),
481 (self.cursor_time.is_some(), "--cursor-time"),
482 (self.cursor_id.is_some(), "--cursor-id"),
483 ])
484 }
485
486 #[must_use]
488 pub(crate) fn first_trace_list_unsupported_flag(&self) -> Option<&'static str> {
489 first_present_flag([
490 (self.name.is_some(), "--name"),
491 (self.user.is_some(), "--user"),
492 (self.trace.is_some(), "--trace"),
493 (self.level.is_some(), "--severity"),
494 (self.search.is_some(), "--search"),
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
502fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
504 flags
505 .iter()
506 .find_map(|(present, flag)| present.then_some(*flag))
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum WatchTarget {
512 All,
514 Logs,
516 Issues,
518 Actions,
520}
521
522#[derive(Debug, Clone, Default, PartialEq, Eq)]
524pub struct WatchOptions {
525 pub severity: Vec<String>,
527}
528
529#[derive(Debug, Clone, Default, PartialEq, Eq)]
531pub struct ProjectSetupSeenOptions {
532 pub runtime: Option<String>,
534 pub source: Option<String>,
536 pub environment: Option<String>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct ProjectCreateOptions {
543 pub name: String,
545 pub runtime: Option<String>,
547 pub environment: Option<String>,
549 pub ingest_key_file: String,
551 pub abandon_retry: bool,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
557pub enum NativeDebugArtifactsTarget {
558 Upload(NativeDebugUploadOptions),
560 Lookup(NativeDebugLookupOptions),
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct NativeDebugLookupOptions {
567 pub project_id: String,
569 pub release: String,
571 pub environment: String,
573 pub service: String,
575 pub image_uuid: String,
577 pub architecture: String,
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct NativeDebugUploadOptions {
584 pub path: String,
586 pub project_id: String,
588 pub release: String,
590 pub environment: String,
592 pub service: String,
594 pub expected_image_uuids: Vec<String>,
596 pub dry_run: bool,
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
602pub enum SupportTarget {
603 Create(Box<SupportTicketCreateOptions>),
605 List(Box<SupportTicketListOptions>),
607 Detail(String),
609 ContextHistory {
611 ticket_id: String,
613 },
614 ReplyContext(Box<SupportContextReplyOptions>),
616 UpdateStatus {
618 ticket_id: String,
620 status: SupportTicketLifecycleStatus,
622 },
623}
624
625#[derive(Debug, Clone, Default, PartialEq, Eq)]
627pub struct SupportContextReplyOptions {
628 pub ticket_id: String,
630 pub context: String,
632 pub retry_key: String,
634 pub diagnostics: bool,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub enum SupportTicketLifecycleStatus {
641 Open,
643 Closed,
645}
646
647impl SupportTicketLifecycleStatus {
648 #[must_use]
650 pub const fn as_str(self) -> &'static str {
651 match self {
652 Self::Open => "open",
653 Self::Closed => "closed",
654 }
655 }
656}
657
658#[derive(Debug, Clone, Default, PartialEq, Eq)]
660pub struct SupportTicketCreateOptions {
661 pub category: String,
663 pub title: String,
665 pub description: String,
667 pub project_id: Option<String>,
669 pub environment: Option<String>,
671 pub runtime: Option<String>,
673 pub framework: Option<String>,
675 pub sdk_package: Option<String>,
677 pub sdk_version: Option<String>,
679 pub release: Option<String>,
681 pub trace_id: Option<String>,
683 pub event_id: Option<String>,
685 pub diagnostics: bool,
687}
688
689#[derive(Debug, Clone, Default, PartialEq, Eq)]
691pub struct SupportTicketListOptions {
692 pub project_id: Option<String>,
694 pub status: Option<String>,
696 pub source: Option<String>,
698 pub category: Option<String>,
700 pub release: Option<String>,
702 pub limit: Option<String>,
704 pub pagination: Option<String>,
706 pub cursor_time: Option<String>,
708 pub cursor_id: Option<String>,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
714pub enum ExplainTarget {
715 Issue(String),
717 Trace(String),
719}
720
721#[derive(Debug, Clone, PartialEq, Eq)]
723pub enum SetTarget {
724 IssueStatus {
726 id: String,
728 status: String,
730 },
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct CliEnvironment {
736 pub base_url: String,
738 pub token: Option<String>,
740 pub home: Option<std::path::PathBuf>,
742 pub cwd: Option<std::path::PathBuf>,
744}
745
746impl CliEnvironment {
747 #[must_use]
749 pub fn from_process() -> Self {
750 Self {
751 base_url: std::env::var("LOGBREW_API_URL")
752 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
753 token: std::env::var("LOGBREW_TOKEN").ok(),
754 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
755 cwd: std::env::current_dir().ok(),
756 }
757 }
758}
759
760impl Command {
761 #[must_use]
763 pub fn http_path(&self) -> Option<String> {
764 match self {
765 Self::Read {
766 target, options, ..
767 } => Some(read_path(
768 target,
769 &ReadPathFilters {
770 name: options.name.as_deref(),
771 service: options.service.as_deref(),
772 since: options.since.as_deref(),
773 user: options.user.as_deref(),
774 trace: options.trace.as_deref(),
775 level: options.level.as_deref(),
776 search: options.search.as_deref(),
777 project: options.project.as_deref(),
778 release: options.release.as_deref(),
779 environment: options.environment.as_deref(),
780 status: options.status.as_deref(),
781 limit: options.limit.as_deref(),
782 min_duration_ms: options.min_duration_ms.as_deref(),
783 pagination: options.pagination.as_deref(),
784 cursor_time: options.cursor_time.as_deref(),
785 cursor_id: options.cursor_id.as_deref(),
786 },
787 )),
788 Self::Explain { target, .. } => Some(explain_path(target)),
789 Self::Set { target, .. } => Some(set_path(target)),
790 Self::ProjectSetupSeen { project_id, .. } => {
791 Some(format!("/api/projects/{project_id}/setup/seen"))
792 }
793 Self::ProjectCreate { .. } | Self::Projects { .. } => {
794 Some(String::from("/api/projects"))
795 }
796 Self::Support { target, .. } => Some(support::path(target)),
797 Self::Help { .. }
798 | Self::Login { .. }
799 | Self::Logout { .. }
800 | Self::Setup { .. }
801 | Self::Status { .. }
802 | Self::WhoAmI { .. }
803 | Self::Doctor { .. }
804 | Self::Usage { .. }
805 | Self::Version { .. }
806 | Self::InvestigateIssue { .. }
807 | Self::NativeDebugArtifacts { .. }
808 | Self::Watch { .. } => None,
809 }
810 }
811
812 #[must_use]
814 pub const fn wants_json(&self) -> bool {
815 match self {
816 Self::Help { json, .. }
817 | Self::Login { json, .. }
818 | Self::Logout { json }
819 | Self::Status { json }
820 | Self::WhoAmI { json }
821 | Self::Doctor { json, .. }
822 | Self::ProjectCreate { json, .. }
823 | Self::Projects { json }
824 | Self::Usage { json }
825 | Self::Version { json }
826 | Self::Read { json, .. }
827 | Self::Watch { json, .. }
828 | Self::Explain { json, .. }
829 | Self::InvestigateIssue { json, .. }
830 | Self::NativeDebugArtifacts { json, .. }
831 | Self::Set { json, .. }
832 | Self::ProjectSetupSeen { json, .. }
833 | Self::Support { json, .. }
834 | Self::Setup { json, .. } => *json,
835 }
836 }
837
838 #[must_use]
840 pub const fn http_method(&self) -> Option<HttpMethod> {
841 match self {
842 Self::ProjectCreate { .. }
843 | Self::ProjectSetupSeen { .. }
844 | Self::Support {
845 target: SupportTarget::Create(_),
846 ..
847 }
848 | Self::Support {
849 target: SupportTarget::ReplyContext(_),
850 ..
851 } => Some(HttpMethod::Post),
852 Self::Support {
853 target: SupportTarget::UpdateStatus { .. },
854 ..
855 }
856 | Self::Set { .. } => Some(HttpMethod::Patch),
857 Self::Projects { .. }
858 | Self::Read { .. }
859 | Self::Explain { .. }
860 | Self::Support { .. } => Some(HttpMethod::Get),
861 Self::Help { .. }
862 | Self::Login { .. }
863 | Self::Logout { .. }
864 | Self::Setup { .. }
865 | Self::Status { .. }
866 | Self::WhoAmI { .. }
867 | Self::Doctor { .. }
868 | Self::Usage { .. }
869 | Self::Version { .. }
870 | Self::InvestigateIssue { .. }
871 | Self::NativeDebugArtifacts { .. }
872 | Self::Watch { .. } => None,
873 }
874 }
875
876 #[must_use]
878 pub fn request_body(&self) -> Option<serde_json::Value> {
879 self.request_body_for_token(None)
880 }
881
882 #[must_use]
884 fn request_body_for_token(&self, token: Option<&str>) -> Option<serde_json::Value> {
885 match self {
886 Self::Set {
887 target: SetTarget::IssueStatus { status, .. },
888 ..
889 } => Some(serde_json::json!({ "status": status })),
890 Self::ProjectSetupSeen { options, .. } => Some(project_setup_seen_body(options, token)),
891 Self::ProjectCreate { options, .. } => Some(project_create_body(options)),
892 Self::Support {
893 target: SupportTarget::Create(options),
894 ..
895 } => Some(support::create_body(options)),
896 Self::Support {
897 target: SupportTarget::ReplyContext(options),
898 ..
899 } => Some(support::context_body(options)),
900 Self::Support {
901 target: SupportTarget::UpdateStatus { status, .. },
902 ..
903 } => Some(serde_json::json!({"status": status.as_str()})),
904 Self::Help { .. }
905 | Self::Login { .. }
906 | Self::Logout { .. }
907 | Self::Setup { .. }
908 | Self::Status { .. }
909 | Self::WhoAmI { .. }
910 | Self::Doctor { .. }
911 | Self::Projects { .. }
912 | Self::Usage { .. }
913 | Self::Version { .. }
914 | Self::Read { .. }
915 | Self::Watch { .. }
916 | Self::Explain { .. }
917 | Self::InvestigateIssue { .. }
918 | Self::NativeDebugArtifacts { .. }
919 | Self::Support { .. } => None,
920 }
921 }
922
923 fn idempotency_key(&self) -> Option<&str> {
925 match self {
926 Self::Support {
927 target: SupportTarget::ReplyContext(options),
928 ..
929 } => Some(options.retry_key.as_str()),
930 Self::Help { .. }
931 | Self::Login { .. }
932 | Self::Logout { .. }
933 | Self::Setup { .. }
934 | Self::Status { .. }
935 | Self::WhoAmI { .. }
936 | Self::Doctor { .. }
937 | Self::Usage { .. }
938 | Self::Version { .. }
939 | Self::Read { .. }
940 | Self::Watch { .. }
941 | Self::Explain { .. }
942 | Self::InvestigateIssue { .. }
943 | Self::NativeDebugArtifacts { .. }
944 | Self::Set { .. }
945 | Self::ProjectSetupSeen { .. }
946 | Self::ProjectCreate { .. }
947 | Self::Projects { .. }
948 | Self::Support { .. } => None,
949 }
950 }
951}
952
953#[derive(Debug, Clone, Copy, PartialEq, Eq)]
955pub enum HttpMethod {
956 Get,
958 Post,
960 Patch,
962}
963
964fn project_setup_seen_body(
966 options: &ProjectSetupSeenOptions,
967 token: Option<&str>,
968) -> serde_json::Value {
969 let mut body = serde_json::Map::new();
970 if let Some(runtime) = options.runtime.as_ref() {
971 drop(body.insert(
972 "runtime".to_owned(),
973 serde_json::Value::String(runtime.clone()),
974 ));
975 }
976 if let Some(source) = setup_seen_source(options, token) {
977 drop(body.insert("source".to_owned(), serde_json::Value::String(source)));
978 }
979 if let Some(environment) = options.environment.as_ref() {
980 drop(body.insert(
981 "environment".to_owned(),
982 serde_json::Value::String(environment.clone()),
983 ));
984 }
985 serde_json::Value::Object(body)
986}
987
988fn project_create_body(options: &ProjectCreateOptions) -> serde_json::Value {
990 let mut body = serde_json::Map::new();
991 drop(body.insert(
992 "name".to_owned(),
993 serde_json::Value::String(options.name.clone()),
994 ));
995 if let Some(runtime) = options.runtime.as_ref() {
996 drop(body.insert(
997 "runtime".to_owned(),
998 serde_json::Value::String(runtime.clone()),
999 ));
1000 }
1001 if let Some(environment) = options.environment.as_ref() {
1002 drop(body.insert(
1003 "environment".to_owned(),
1004 serde_json::Value::String(environment.clone()),
1005 ));
1006 }
1007 drop(body.insert(
1008 "source".to_owned(),
1009 serde_json::Value::String(String::from("cli")),
1010 ));
1011 serde_json::Value::Object(body)
1012}
1013
1014fn setup_seen_source(options: &ProjectSetupSeenOptions, token: Option<&str>) -> Option<String> {
1016 if token_is_project_ingest_key(token) {
1017 return None;
1018 }
1019 Some(options.source.as_deref().unwrap_or("cli").to_owned())
1020}
1021
1022pub async fn execute_command<W: std::io::Write>(
1028 command: &Command,
1029 env: &CliEnvironment,
1030 output: &mut W,
1031) -> Result<(), RuntimeError> {
1032 match command {
1033 Command::Help { topic, json } => execute_help(*topic, *json, output),
1034 Command::Login {
1035 provider,
1036 open_browser,
1037 json,
1038 } => execute_login(env, *provider, *open_browser, *json, output).await,
1039 Command::Logout { json } => execute_logout(env, *json, output).await,
1040 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
1041 Command::Status { json } => execute_status(env, *json, output).await,
1042 Command::WhoAmI { json } => execute_whoami(env, *json, output).await,
1043 Command::Doctor { project_id, json } => {
1044 doctor::execute(env, project_id.as_str(), *json, output).await
1045 }
1046 Command::ProjectCreate { options, json } => {
1047 project_create::execute(env, options, *json, output).await
1048 }
1049 Command::Projects { json } => projects::execute(env, *json, output).await,
1050 Command::Usage { json } => usage::execute(env, *json, output).await,
1051 Command::Version { json } => execute_version(*json, output),
1052 Command::InvestigateIssue { issue_id, json } => {
1053 investigate::execute(env, issue_id.as_str(), *json, output).await
1054 }
1055 Command::NativeDebugArtifacts { target, json } => {
1056 native_debug_artifacts::execute(env, target, *json, output).await
1057 }
1058 Command::Read { .. }
1059 | Command::Explain { .. }
1060 | Command::Set { .. }
1061 | Command::ProjectSetupSeen { .. }
1062 | Command::Support { .. } => execute_http(command, env, output).await,
1063 Command::Watch {
1064 target,
1065 options,
1066 json,
1067 } => execute_watch(env, *target, options, *json, output).await,
1068 }
1069}
1070
1071fn execute_help<W: std::io::Write>(
1073 topic: HelpTopic,
1074 json: bool,
1075 output: &mut W,
1076) -> Result<(), RuntimeError> {
1077 let help = help::help_text(topic);
1078 if json {
1079 let body = serde_json::json!({
1080 "ok": true,
1081 "topic": topic.key(),
1082 "help": help,
1083 });
1084 writeln!(output, "{body}")?;
1085 } else {
1086 writeln!(output, "{help}")?;
1087 }
1088 Ok(())
1089}
1090
1091fn execute_setup<W: std::io::Write>(
1093 env: &CliEnvironment,
1094 auto: bool,
1095 yes: bool,
1096 json: bool,
1097 output: &mut W,
1098) -> Result<(), RuntimeError> {
1099 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
1100 Ok(())
1101}
1102
1103async fn execute_http<W: std::io::Write>(
1105 command: &Command,
1106 env: &CliEnvironment,
1107 output: &mut W,
1108) -> Result<(), RuntimeError> {
1109 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
1110 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
1111 let client = reqwest::Client::builder()
1112 .timeout(std::time::Duration::from_secs(30))
1113 .connect_timeout(std::time::Duration::from_secs(10))
1114 .build()?;
1115
1116 let support_command = matches!(command, Command::Support { .. });
1117 let response_result = send_authenticated_with_refresh(&client, env, |client, credential| {
1118 build_command_request(client, command, url.as_str(), credential)
1119 })
1120 .await;
1121 let (response, credential) = match response_result {
1122 Ok(response) => response,
1123 Err(RuntimeError::Http(_)) if support_command => return Err(support_transport_error()),
1124 Err(error) => return Err(error),
1125 };
1126 let status = response.status();
1127 let body = match response.text().await {
1128 Ok(body) => body,
1129 Err(_) if support_command => return Err(support_transport_error()),
1130 Err(error) => return Err(RuntimeError::Http(error)),
1131 };
1132
1133 if !status.is_success() {
1134 let body = if let Command::Support { target, .. } = command {
1135 support::safe_error_body(target, status.as_u16())
1136 } else {
1137 credential.redact_response_body(body.as_str())
1138 };
1139 return Err(RuntimeError::Api {
1140 status: status.as_u16(),
1141 body,
1142 auth_source: credential.source(),
1143 auth_label: credential.label(),
1144 });
1145 }
1146
1147 write_api_success(command, body.as_str(), output)?;
1148 Ok(())
1149}
1150
1151const fn support_transport_error() -> RuntimeError {
1153 RuntimeError::Unavailable {
1154 message: "support request could not be completed",
1155 next: "check network connectivity and retry the support command",
1156 }
1157}
1158
1159fn build_command_request(
1161 client: &reqwest::Client,
1162 command: &Command,
1163 url: &str,
1164 credential: &AuthCredential,
1165) -> reqwest::RequestBuilder {
1166 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
1167 HttpMethod::Get => client.get(url),
1168 HttpMethod::Post => client.post(url),
1169 HttpMethod::Patch => client.patch(url),
1170 }
1171 .bearer_auth(credential.token());
1172 if let Some(body) = command.request_body_for_token(Some(credential.token())) {
1173 request = request.json(&body);
1174 }
1175 if let Some(key) = command.idempotency_key() {
1176 request = request.header("Idempotency-Key", key);
1177 }
1178 request
1179}
1180
1181async fn execute_watch<W: std::io::Write>(
1183 env: &CliEnvironment,
1184 target: WatchTarget,
1185 options: &WatchOptions,
1186 json: bool,
1187 output: &mut W,
1188) -> Result<(), RuntimeError> {
1189 if !json {
1190 return Err(RuntimeError::Unavailable {
1191 message: "watch streams JSON for agents",
1192 next: "run logbrew watch --json",
1193 });
1194 }
1195
1196 let mut reconnect_backoff = WatchReconnectBackoff::default();
1197 loop {
1198 let ticket = match request_feed_ticket(env).await {
1199 Ok(ticket) => ticket,
1200 Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
1201 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1202 continue;
1203 }
1204 Err(error) => return Err(error),
1205 };
1206 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
1207 let (mut websocket, _) = match connect_async(live_url.as_str()).await {
1208 Ok(connection) => connection,
1209 Err(error)
1210 if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
1211 {
1212 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1213 continue;
1214 }
1215 Err(error) => return Err(map_websocket_connect_error(error)),
1216 };
1217 reconnect_backoff.mark_connected();
1218
1219 let mut emitted_before_disconnect = false;
1220 loop {
1221 let Some(message) = websocket.next().await else {
1222 break;
1223 };
1224 let message = match message {
1225 Ok(message) => message,
1226 Err(error) if websocket_error_is_auth(&error) => {
1227 return Err(map_websocket_stream_error(error));
1228 }
1229 Err(_) => break,
1230 };
1231 match message {
1232 Message::Text(text) => {
1233 let event = parse_live_event(text.as_str())?;
1234 if watch_event_matches(target, options, &event) {
1235 writeln!(output, "{event}")?;
1236 }
1237 emitted_before_disconnect = true;
1238 }
1239 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
1240 Message::Close(_) => return Ok(()),
1241 }
1242 }
1243 if emitted_before_disconnect {
1244 reconnect_backoff.reset();
1245 }
1246 tokio::time::sleep(reconnect_backoff.next_delay()).await;
1247 }
1248}
1249
1250#[derive(Debug, Default)]
1252struct WatchReconnectBackoff {
1253 connected_once: bool,
1255 attempts: u32,
1257}
1258
1259impl WatchReconnectBackoff {
1260 const fn connected_once(&self) -> bool {
1262 self.connected_once
1263 }
1264
1265 const fn mark_connected(&mut self) {
1267 self.connected_once = true;
1268 }
1269
1270 const fn reset(&mut self) {
1272 self.attempts = 0;
1273 }
1274
1275 fn next_delay(&mut self) -> std::time::Duration {
1277 let exponent = self.attempts.min(5);
1278 let multiplier = 1_u64 << exponent;
1279 self.attempts = self.attempts.saturating_add(1);
1280 let base = WATCH_RECONNECT_INITIAL_DELAY
1281 .as_secs()
1282 .saturating_mul(multiplier)
1283 .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
1284 let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
1285 if delay > WATCH_RECONNECT_MAX_DELAY {
1286 WATCH_RECONNECT_MAX_DELAY
1287 } else {
1288 delay
1289 }
1290 }
1291}
1292
1293fn watch_reconnect_jitter() -> std::time::Duration {
1295 let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
1296 return std::time::Duration::ZERO;
1297 };
1298 std::time::Duration::from_millis(
1299 u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
1300 )
1301}
1302
1303const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
1305 matches!(
1306 error,
1307 RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
1308 )
1309}
1310
1311fn websocket_error_is_auth(error: &WebSocketError) -> bool {
1313 matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
1314}
1315
1316async fn request_feed_ticket(env: &CliEnvironment) -> Result<String, RuntimeError> {
1318 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
1319 let client = reqwest::Client::builder()
1320 .timeout(std::time::Duration::from_secs(30))
1321 .connect_timeout(std::time::Duration::from_secs(10))
1322 .build()?;
1323 let (response, credential) =
1324 send_authenticated_with_refresh(&client, env, |client, credential| {
1325 client.post(url.as_str()).bearer_auth(credential.token())
1326 })
1327 .await?;
1328 let status = response.status();
1329 let body = response.text().await?;
1330 if !status.is_success() {
1331 return Err(RuntimeError::Api {
1332 status: status.as_u16(),
1333 body: credential.redact_response_body(body.as_str()),
1334 auth_source: credential.source(),
1335 auth_label: credential.label(),
1336 });
1337 }
1338
1339 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
1340 RuntimeError::Unavailable {
1341 message: "feed ticket response was not valid JSON",
1342 next: "retry logbrew watch or run logbrew status",
1343 }
1344 })?;
1345 value
1346 .get("ticket")
1347 .and_then(serde_json::Value::as_str)
1348 .map(str::trim)
1349 .filter(|ticket| !ticket.is_empty())
1350 .map(ToOwned::to_owned)
1351 .ok_or(RuntimeError::Unavailable {
1352 message: "feed ticket response did not include a ticket",
1353 next: "retry logbrew watch or run logbrew status",
1354 })
1355}
1356
1357fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
1359 let trimmed = base_url.trim_end_matches('/');
1360 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
1361 message: "LOGBREW_API_URL must start with http:// or https://",
1362 next: "check LOGBREW_API_URL or run logbrew status",
1363 })?;
1364 Ok(format!(
1365 "{scheme}://{rest}/api/feed/live?ticket={}",
1366 encode_component(ticket)
1367 ))
1368}
1369
1370fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
1372 base_url
1373 .strip_prefix("https://")
1374 .map(|rest| ("wss", rest))
1375 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
1376}
1377
1378fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
1380 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
1381 message: "live watch event was not valid JSON",
1382 next: "retry logbrew watch or check LOGBREW_API_URL",
1383 })
1384}
1385
1386fn watch_event_matches(
1388 target: WatchTarget,
1389 options: &WatchOptions,
1390 event: &serde_json::Value,
1391) -> bool {
1392 target_matches_event(target, event) && severity_matches(options, event)
1393}
1394
1395fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
1397 let event_type = event
1398 .get("type")
1399 .and_then(serde_json::Value::as_str)
1400 .unwrap_or_default();
1401 match target {
1402 WatchTarget::All => true,
1403 WatchTarget::Logs => event_type == "native_log",
1404 WatchTarget::Issues => event_type == "native_issue",
1405 WatchTarget::Actions => event_type == "native_action",
1406 }
1407}
1408
1409fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
1411 if options.severity.is_empty() {
1412 return true;
1413 }
1414 let Some(severity) = event
1415 .get("data")
1416 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
1417 .and_then(serde_json::Value::as_str)
1418 else {
1419 return false;
1420 };
1421 options
1422 .severity
1423 .iter()
1424 .any(|allowed| allowed.as_str() == severity)
1425}
1426
1427fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
1429 match error {
1430 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1431 RuntimeError::Unavailable {
1432 message: "live watch ticket was rejected",
1433 next: "run logbrew login",
1434 }
1435 }
1436 WebSocketError::Http(_) => RuntimeError::Unavailable {
1437 message: "live watch websocket upgrade failed",
1438 next: "retry logbrew watch or check LOGBREW_API_URL",
1439 },
1440 WebSocketError::ConnectionClosed
1441 | WebSocketError::AlreadyClosed
1442 | WebSocketError::Io(_)
1443 | WebSocketError::Tls(_)
1444 | WebSocketError::Capacity(_)
1445 | WebSocketError::Protocol(_)
1446 | WebSocketError::WriteBufferFull(_)
1447 | WebSocketError::Utf8(_)
1448 | WebSocketError::AttackAttempt
1449 | WebSocketError::Url(_)
1450 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1451 message: "live watch websocket failed",
1452 next: "retry logbrew watch or check LOGBREW_API_URL",
1453 },
1454 }
1455}
1456
1457fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
1459 match error {
1460 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
1461 RuntimeError::Unavailable {
1462 message: "live watch websocket closed",
1463 next: "retry logbrew watch",
1464 }
1465 }
1466 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1467 RuntimeError::Unavailable {
1468 message: "live watch ticket was rejected",
1469 next: "run logbrew login",
1470 }
1471 }
1472 WebSocketError::Http(_)
1473 | WebSocketError::Io(_)
1474 | WebSocketError::Tls(_)
1475 | WebSocketError::Capacity(_)
1476 | WebSocketError::Protocol(_)
1477 | WebSocketError::WriteBufferFull(_)
1478 | WebSocketError::Utf8(_)
1479 | WebSocketError::AttackAttempt
1480 | WebSocketError::Url(_)
1481 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1482 message: "live watch websocket failed",
1483 next: "retry logbrew watch or check LOGBREW_API_URL",
1484 },
1485 }
1486}
1487
1488struct ReadPathFilters<'a> {
1490 name: Option<&'a str>,
1492 service: Option<&'a str>,
1494 since: Option<&'a str>,
1496 user: Option<&'a str>,
1498 trace: Option<&'a str>,
1500 level: Option<&'a str>,
1502 search: Option<&'a str>,
1504 project: Option<&'a str>,
1506 release: Option<&'a str>,
1508 environment: Option<&'a str>,
1510 status: Option<&'a str>,
1512 limit: Option<&'a str>,
1514 min_duration_ms: Option<&'a str>,
1516 pagination: Option<&'a str>,
1518 cursor_time: Option<&'a str>,
1520 cursor_id: Option<&'a str>,
1522}
1523
1524fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
1526 match target {
1527 ReadTarget::Logs => path_with_query(
1528 "/api/logs",
1529 &[
1530 ("service_name", filters.service),
1531 ("severity", filters.level),
1532 ("search", filters.search),
1533 ("since", filters.since),
1534 ("trace_id", filters.trace),
1535 ("project_id", filters.project),
1536 ("release", filters.release),
1537 ("environment", filters.environment),
1538 ("pagination", filters.pagination),
1539 ("cursor_time", filters.cursor_time),
1540 ("cursor_id", filters.cursor_id),
1541 ("limit", filters.limit),
1542 ],
1543 ),
1544 ReadTarget::Issues => path_with_query(
1545 "/api/telemetry/issues",
1546 &[
1547 ("service_name", filters.service),
1548 ("since", filters.since),
1549 ("status", filters.status),
1550 ("project_id", filters.project),
1551 ("release", filters.release),
1552 ("environment", filters.environment),
1553 ("pagination", filters.pagination),
1554 ("cursor_time", filters.cursor_time),
1555 ("cursor_id", filters.cursor_id),
1556 ("limit", filters.limit),
1557 ],
1558 ),
1559 ReadTarget::Actions => path_with_query(
1560 "/api/telemetry/actions",
1561 &[
1562 ("service_name", filters.service),
1563 ("name", filters.name),
1564 ("since", filters.since),
1565 ("distinct_id", filters.user),
1566 ("project_id", filters.project),
1567 ("release", filters.release),
1568 ("environment", filters.environment),
1569 ("pagination", filters.pagination),
1570 ("cursor_time", filters.cursor_time),
1571 ("cursor_id", filters.cursor_id),
1572 ("limit", filters.limit),
1573 ],
1574 ),
1575 ReadTarget::Releases => path_with_query(
1576 "/api/telemetry/releases",
1577 &[
1578 ("service_name", filters.service),
1579 ("since", filters.since),
1580 ("project_id", filters.project),
1581 ("release", filters.release),
1582 ("environment", filters.environment),
1583 ("limit", filters.limit),
1584 ],
1585 ),
1586 ReadTarget::Traces => path_with_query(
1587 "/api/telemetry/traces",
1588 &[
1589 ("project_id", filters.project),
1590 ("service_name", filters.service),
1591 ("release", filters.release),
1592 ("environment", filters.environment),
1593 ("status", filters.status),
1594 ("since", filters.since),
1595 ("min_duration_ms", filters.min_duration_ms),
1596 ("limit", filters.limit),
1597 ],
1598 ),
1599 ReadTarget::Trace(id) => path_with_query(
1600 &format!("/api/telemetry/traces/{}", encode_component(id)),
1601 &[
1602 ("project_id", filters.project),
1603 ("release", filters.release),
1604 ("environment", filters.environment),
1605 ],
1606 ),
1607 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1608 }
1609}
1610
1611fn explain_path(target: &ExplainTarget) -> String {
1613 match target {
1614 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1615 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1616 }
1617}
1618
1619fn set_path(target: &SetTarget) -> String {
1621 match target {
1622 SetTarget::IssueStatus { id, .. } => {
1623 format!("/api/telemetry/issues/{}", encode_component(id))
1624 }
1625 }
1626}
1627
1628fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1630 let query = params
1631 .iter()
1632 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1633 .collect::<Vec<_>>();
1634
1635 if query.is_empty() {
1636 path.to_owned()
1637 } else {
1638 format!("{path}?{}", query.join("&"))
1639 }
1640}
1641
1642fn encode_component(value: &str) -> String {
1644 let mut encoded = String::new();
1645 for byte in value.bytes() {
1646 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1647 encoded.push(char::from(byte));
1648 } else {
1649 encoded.push('%');
1650 encoded.push(hex_digit(byte >> 4));
1651 encoded.push(hex_digit(byte & 0x0f));
1652 }
1653 }
1654 encoded
1655}
1656
1657fn hex_digit(nibble: u8) -> char {
1659 match nibble {
1660 0..=9 => char::from(b'0' + nibble),
1661 10..=15 => char::from(b'A' + (nibble - 10)),
1662 _ => '?',
1663 }
1664}