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